refactor: rewrite

start working on full rewrite [part 1]
This commit is contained in:
xHyroM 2023-07-21 22:58:45 +02:00
parent 79a90da9a6
commit a5f1742918
40 changed files with 252 additions and 1511 deletions

View file

@ -13,21 +13,20 @@ jobs:
ref: refs/pull/${{ github.event.number }}/merge ref: refs/pull/${{ github.event.number }}/merge
- name: Setup Bun - name: Setup Bun
uses: xhyrom/setup-bun@v0.1.3 uses: oven-sh/setup-bun@v1
with: with:
bun-version: latest bun-version: latest
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Get changed files - name: Get changed files
uses: Mineflash07/gh-action-get-changed-files@feature/support-pr-target-event uses: Mineflash07/gh-action-get-changed-files@feature/support-pr-target-event
with: with:
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
- name: Add json - name: Update files.json file
run: cp $HOME/files.json ./scripts/validateTags/ run: cp $HOME/files.json ./scripts/validate_tags/
- name: Validate tag - name: Validate tags
run: bun run validate run: bun run validate:tags
env: env:
github-token: ${{ secrets.GITHUB_TOKEN }} github-token: ${{ secrets.GITHUB_TOKEN }}
commit-sha: ${{ github.event.pull_request.head.sha }} commit-sha: ${{ github.event.pull_request.head.sha }}

2
.gitignore vendored
View file

@ -2,8 +2,6 @@
# Edit at https://www.toptal.com/developers/gitignore?templates=node # Edit at https://www.toptal.com/developers/gitignore?templates=node
files/database.sqlite files/database.sqlite
files/config.toml
requests.rest
### Node ### ### Node ###
# Logs # Logs

View file

@ -1,5 +0,0 @@
{
"tabWidth": 4,
"singleQuote": true,
"trailingComma": "es5"
}

BIN
bun.lockb

Binary file not shown.

View file

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

View file

@ -1,4 +1,3 @@
[github]
repositories = [ repositories = [
"oven-sh/bun", "oven-sh/bun",
"xHyroM/bun-discord-bot" "xHyroM/bun-discord-bot"

View file

@ -3,19 +3,13 @@
"name": "bun-discord-bot", "name": "bun-discord-bot",
"scripts": { "scripts": {
"start": "bun src/index.ts", "start": "bun src/index.ts",
"validate": "cd scripts/validateTags && bun install && bun start", "validate:tags": "cd scripts/validate_tags && bun install && bun start"
"cloudflare:tunnel": "sudo cloudflared tunnel run"
}, },
"devDependencies": { "devDependencies": {
"bun-types": "^0.1.8" "bun-types": "^0.7.0"
}, },
"dependencies": { "dependencies": {
"@discordjs/collection": "^0.7.0", "@paperdave/logger": "^3.0.1",
"bun-utilities": "^0.2.1", "discord.js": "^14.11.0"
"create-hmac": "^1.1.7",
"discord-api-types": "^0.36.1",
"hono": "^1.6.4",
"minisearch": "^5.0.0",
"tweetnacl": "^1.0.3"
} }
} }

View file

@ -1,3 +0,0 @@
[
"files/tags.toml"
]

View file

@ -1,6 +0,0 @@
{
"name": "validate-tags",
"scripts": {
"start": "bun src/index.ts"
}
}

View file

@ -1,115 +0,0 @@
interface Tag {
keywords: string[];
content: string;
}
const githubToken = process.env['github-token'];
const commitSha = process.env['commit-sha'];
const pullRequestNumber = process.env['pr-number'];
const codeBlockRegex = /(`{1,3}).+?\1/gs;
const urlRegex = /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&\/\/=]*)/gi;
const files = await Bun.file('./files.json').text();
if (!files.includes('files/tags.toml')) process.exit(0);
const errors = [];
let tags;
try {
// @ts-expect-error types
tags = (await import('../../../files/tags.toml')).default;
requestGithub(
`issues/${pullRequestNumber}/labels`,
{
labels: ['tags']
}
);
} catch(e) {
tags = [];
errors.push(e.message);
}
for (const [key, value] of Object.entries(tags)) {
const tag = value as Tag;
if (!tag?.keywords || tag.keywords.length === 0) errors.push(`**[${key}]:** Tag must have keywords`);
if (tag?.keywords?.[0] !== key) errors.push(`**[${key}]:** First keyword of tag is not the same as the tag name`);
if (!tag.content) errors.push(`**[${key}]:** Tag must have content`);
if (tag.content) {
const cleanedContent = tag.content.replaceAll('+++', '```').replace(codeBlockRegex, '');
for (const url of cleanedContent.match(urlRegex) || []) {
const firstChar = tag.content.split(url)[0].slice(-1);
const lastChar = tag.content.split(url)[1].slice(0, 1);
if (
firstChar !== '<' ||
lastChar !== '>'
) errors.push(`**[${key}]:** Link must be wrapped in <>`);
}
}
if (tag.keywords) {
const keywords = [...new Set(tag.keywords)];
if (keywords.length !== tag.keywords.length) errors.push(`**[${key}]:** Keywords must be unique`);
}
}
if (errors.length === 0) {
requestGithub(
`pulls/${pullRequestNumber}/reviews`,
{
commit_id: commitSha,
event: 'APPROVE',
}
);
requestGithub(
`pulls/${pullRequestNumber}/requested_reviewers`,
{
reviewers: ['xHyroM']
}
);
requestGithub(
`issues/${pullRequestNumber}/labels/waiting`,
{},
'DELETE'
);
requestGithub(
`issues/${pullRequestNumber}/labels`,
{
labels: ['ready']
}
);
} else {
requestGithub(
`pulls/${pullRequestNumber}/reviews`,
{
commit_id: commitSha,
body: '### Please fix the following problems:\n' + errors.join('\n'),
event: 'REQUEST_CHANGES',
}
);
requestGithub(
`issues/${pullRequestNumber}/labels`,
{
labels: ['waiting']
}
);
}
function requestGithub(url: string, body: any, method?: 'POST' | 'DELETE') {
fetch(`https://api.github.com/repos/xHyroM/bun-discord-bot/${url}`, {
method: method || 'POST',
headers: {
'Accept': 'application/vnd.github+json',
'Authorization': `token ${githubToken}`
},
body: JSON.stringify(body)
})
}
export { };

View file

@ -0,0 +1 @@
["files/tags.toml"]

View file

@ -0,0 +1,6 @@
{
"name": "validate_tags",
"scripts": {
"start": "bun src/index.ts"
}
}

View file

@ -0,0 +1,108 @@
interface Tag {
keywords: string[];
content: string;
}
const githubToken = process.env['github-token'];
const commitSha = process.env['commit-sha'];
const pullRequestNumber = process.env['pr-number'];
const codeBlockRegex = /(`{1,3}).+?\1/gs;
const urlRegex =
/https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&\/\/=]*)/gi;
// Check if files/tags.toml was changed
const files = await Bun.file('./files.json').text();
if (!files.includes('files/tags.toml')) process.exit(0);
const errors = [];
let tags;
try {
tags = (await import('../../../files/tags.toml')).default;
await requestGithub(`issues/${pullRequestNumber}/labels`, {
labels: ['tags'],
});
} catch (e) {
tags = [];
errors.push(e.message);
}
for (const [key, value] of Object.entries(tags)) {
const tag = value as Tag;
if (!tag?.keywords || tag.keywords.length === 0)
errors.push(`**[${key}]:** Tag must have keywords`);
if (tag?.keywords?.[0] !== key)
errors.push(
`**[${key}]:** First keyword of tag is not the same as the tag name`
);
if (!tag.content) errors.push(`**[${key}]:** Tag must have content`);
if (tag.content) {
const cleanedContent = tag.content
.replaceAll('+++', '```')
.replace(codeBlockRegex, '');
for (const url of cleanedContent.match(urlRegex) || []) {
const firstChar = tag.content.split(url)[0].slice(-1);
const lastChar = tag.content.split(url)[1].slice(0, 1);
if (firstChar !== '<' || lastChar !== '>')
errors.push(`**[${key}]:** Link must be wrapped in <>`);
}
}
if (tag.keywords) {
const keywords = [...new Set(tag.keywords)];
if (keywords.length !== tag.keywords.length)
errors.push(`**[${key}]:** Keywords must be unique`);
}
}
if (errors.length === 0) {
await requestGithub(`pulls/${pullRequestNumber}/reviews`, {
commit_id: commitSha,
event: 'APPROVE',
});
await requestGithub(`pulls/${pullRequestNumber}/requested_reviewers`, {
reviewers: ['xHyroM'],
});
await requestGithub(
`issues/${pullRequestNumber}/labels/waiting`,
{},
'DELETE'
);
await requestGithub(`issues/${pullRequestNumber}/labels`, {
labels: ['ready'],
});
} else {
await requestGithub(`pulls/${pullRequestNumber}/reviews`, {
commit_id: commitSha,
body: '### Please fix the following problems:\n' + errors.join('\n'),
event: 'REQUEST_CHANGES',
});
await requestGithub(`issues/${pullRequestNumber}/labels`, {
labels: ['waiting'],
});
}
async function requestGithub(
url: string,
body: any,
method?: 'POST' | 'DELETE'
) {
await fetch(`https://api.github.com/repos/xHyroM/bun-discord-bot/${url}`, {
method: method || 'POST',
headers: {
Accept: 'application/vnd.github+json',
Authorization: `token ${githubToken}`,
},
body: JSON.stringify(body),
});
}
export {};

4
scripts/validate_tags/src/types.d.ts vendored Normal file
View file

@ -0,0 +1,4 @@
declare module '*.toml' {
const value: any;
export default value;
}

39
src/Bient.ts Normal file
View file

@ -0,0 +1,39 @@
import { info } from "@paperdave/logger";
import { Client, ClientOptions } from "discord.js";
import { redactToken } from "./utils";
export class Bient extends Client {
public static instance: Bient;
constructor(options: ClientOptions) {
super(options);
Bient.instance = this;
}
public async load(): Promise<void> {
info("Loading listeners and commands...");
const start = performance.now();
await this.loadListeners();
await this.loadCommands();
const end = performance.now();
info(`Loaded listeners and commands in ${end - start}ms`);
}
public async loadCommands(): Promise<void> {
await import("./commands/Ping.ts");
}
public async loadListeners(): Promise<void> {
await import("./listeners/Ready.ts");
}
public login(): Promise<string> {
info(`Logging in using ${redactToken(process.env.BOT_TOKEN)}`);
return super.login(process.env.BOT_TOKEN);
}
}

9
src/commands/Ping.ts Normal file
View file

@ -0,0 +1,9 @@
import { CommandInteraction } from "discord.js";
import { Command } from "../decorators/Command";
@Command("ping")
export class Ping {
async run(context: CommandInteraction) {
context.reply("Pong!");
}
}

View file

@ -1,135 +0,0 @@
import { APIApplicationCommandInteractionDataStringOption, ApplicationCommandOptionType, InteractionResponseType, MessageFlags } from 'discord-api-types/v10';
import { Command } from '../structures/Command';
// @ts-expect-error Types :(
import utilities from '../../files/utilities.toml';
import { CommandContext } from '../structures/contexts/CommandContext';
import { getIssueOrPR, search, formatStatus, formatEmojiStatus, IssueState, IssueType } from '../utils/githubUtils';
const invalidIssue = (ctx: CommandContext, query: string) => {
return ctx.editResponse(
`\`\` Invalid issue or pull request \`${query}\`. You can check [github search syntax](https://docs.github.com/en/search-github/searching-on-github/searching-issues-and-pull-requests)`
);
}
new Command({
name: 'github',
description: 'Query an issue, pull request or direct link to Github Issue or PR',
options: [
{
name: 'query',
description: 'Issue numer/name, PR number/name or direct link to Github Issue or PR',
type: ApplicationCommandOptionType.String,
required: true,
run: async(ctx) => {
return ctx.respond(
await search(
ctx.value,
(ctx.options.find(o => o.name === 'repository'))?.value as string || 'oven-sh/bun',
(ctx.options.find(o => o.name === 'state')?.value as string || 'all') as IssueState,
(ctx.options.find(o => o.name === 'type')?.value as string || '(IS|PR)') as IssueType,
)
);
}
},
{
name: 'state',
description: 'Issue or PR state',
type: ApplicationCommandOptionType.String,
required: false,
choices: [
{
name: 'open',
value: 'open',
},
{
name: 'closed',
value: 'closed',
},
{
name: 'merged',
value: 'merged',
},
{
name: 'all (default)',
value: 'all',
}
]
},
{
name: 'type',
description: 'Issues or PRs',
type: ApplicationCommandOptionType.String,
required: false,
choices: [
{
name: 'Issues',
value: '(IS)',
},
{
name: 'Pull Requests',
value: '(PR)',
},
{
name: 'Both',
value: '(IS|PR)',
}
]
},
{
name: 'repository',
description: 'Project repository (default oven-sh/bun)',
type: ApplicationCommandOptionType.String,
required: false,
choices: [
...utilities.github.repositories.map(repository => new Object({
name: repository.split('/')[1],
value: repository
}))
]
},
],
run: async(ctx) => {
ctx.command.runEditResponse(ctx)
return ctx.respond({
type: InteractionResponseType.DeferredChannelMessageWithSource
})
},
runEditResponse: async(ctx) => {
let query: string = (ctx.options[0] as APIApplicationCommandInteractionDataStringOption).value;
const repository: string = (ctx.options.find(o => o.name === 'repository') as APIApplicationCommandInteractionDataStringOption)?.value || 'oven-sh/bun';
const state: IssueState = ((ctx.options.find(o => o.name === 'state') as APIApplicationCommandInteractionDataStringOption)?.value || 'all') as IssueState;
const type: IssueType = ((ctx.options.find(o => o.name === 'type') as APIApplicationCommandInteractionDataStringOption)?.value || '(IS|PR)') as IssueType;
const repositorySplit = repository.split('/');
const repositoryOwner = repositorySplit[0];
const repositoryName = repositorySplit[1];
let issueOrPR = await getIssueOrPR(parseInt(query), repository, state, type);
if (!issueOrPR) {
const res = await fetch(`https://api.github.com/search/issues?q=${encodeURIComponent(query)}${encodeURIComponent(' repo:oven-sh/bun')}`);
const data: any = await res.json();
if (data.message || data?.items?.length === 0) return invalidIssue(ctx, query);
const item = data.items[0];
issueOrPR = {
id: item.number,
repository: item.repository_url.replace('https://api.github.com/repos/', ''),
title: item.title,
number: item.number,
state: item.state,
created_at: item.created_at,
closed_at: item.closed_at,
html_url: item.html_url,
user_login: item.user.login,
user_html_url: item.user.html_url,
type: item.pull_request ? '(PR)' : '(IS)',
};
}
return ctx.editResponse([
`${formatEmojiStatus(issueOrPR)} [#${issueOrPR.number} ${issueOrPR.title.replace(/\[\]/g, '').slice(0, 1500)} (${repositoryOwner}/${repositoryName})](<${issueOrPR.html_url}>) by [${issueOrPR.user_login}](<${issueOrPR.user_html_url}>) ${formatStatus(issueOrPR)}`,
issueOrPR.title
].join('\n'));
}
})

View file

@ -1,16 +0,0 @@
import { InteractionResponseType, MessageFlags } from 'discord-api-types/v10';
import { Command } from '../structures/Command';
new Command({
name: 'ping',
description: 'pong',
run: (ctx) => {
return ctx.respond({
type: InteractionResponseType.ChannelMessageWithSource,
data: {
content: 'Pong 🏓',
flags: MessageFlags.Ephemeral,
}
})
}
})

View file

@ -1,54 +0,0 @@
import {
APIApplicationCommandInteractionDataStringOption,
ApplicationCommandOptionType,
InteractionResponseType,
MessageFlags,
} from 'discord-api-types/v10';
import { Command } from '../structures/Command';
import { findTags, getTag } from '../utils/tagsUtils';
new Command({
name: 'tag',
description: 'Send a tag by name or alias',
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: (ctx) => {
const query: APIApplicationCommandInteractionDataStringOption = ctx
.options[0] as APIApplicationCommandInteractionDataStringOption;
const target = ctx?.resolved?.users
? Object.values(ctx?.resolved?.users)[0]
: null;
const tag = getTag(query.value, false);
if (!tag)
return ctx.respond({
type: InteractionResponseType.ChannelMessageWithSource,
data: {
content: `\`\` Could not find a tag \`${query.value}\``,
flags: MessageFlags.Ephemeral,
},
});
return ctx.respond(
[
target ? `*Tag suggestion for <@${target.id}>:*` : '',
tag.content,
].join('\n')
);
},
});

View file

@ -1,22 +0,0 @@
import { InteractionResponseType, MessageFlags } from 'discord-api-types/v10';
import { Command } from '../structures/Command';
import { exec } from 'bun-utilities/spawn';
const commitHash = exec(['git', 'log', '--pretty=format:\'%h\'', '-n', '1']).stdout.replaceAll('\'', '');
new Command({
name: 'version',
description: 'Check bot and bun version',
run: (ctx) => {
return ctx.respond({
type: InteractionResponseType.ChannelMessageWithSource,
data: {
content: [
`Bot version: [git-bun-discord-bot-"${commitHash}"](<https://github.com/xHyroM/bun-discord-bot/commit/${commitHash}>)`,
`Bun version: [${process.version}](<https://github.com/oven-sh/bun/releases/tag/bun-${process.version}>)`,
].join('\n'),
flags: MessageFlags.Ephemeral,
}
})
}
})

View file

@ -0,0 +1,6 @@
import { ClientEvents } from "discord.js";
import { Bient } from "../Bient";
export function Command(name: string) {
return (constructor: Function, context: ClassDecoratorContext) => {};
}

View file

@ -0,0 +1,12 @@
import { ClientEvents } from "discord.js";
import { Bient } from "../Bient";
export function Listener(event: keyof ClientEvents) {
return (constructor: Function, context: ClassMethodDecoratorContext) => {
const name = context as unknown as string;
Bient.instance.on.bind(Bient.instance)(event, (...args) =>
constructor[name]([...args])
);
};
}

View file

@ -1,222 +1,10 @@
import { Hono } from 'hono'; import { GatewayIntentBits } from "discord.js";
import { bodyParse } from 'hono/body-parse'; import { Bient } from "./Bient.ts";
import { Logger } from './utils/Logger';
// @ts-expect-error Types :( const client = new Bient({
import config from '../files/config.toml'; intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages],
import loadCommands from './utils/loadCommands';
import { verifyGithubKey, verifyKey } from './utils/verify';
import {
APIPingInteraction,
APIApplicationCommandInteraction,
APIMessageComponentInteraction,
InteractionType,
InteractionResponseType,
ApplicationCommandType,
APIApplicationCommandAutocompleteInteraction,
ApplicationCommandOptionType,
} 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';
import {
deleteIssueOrPR,
fetchIssues,
fetchPullRequests,
setIssue,
setPullRequest,
} from './utils/githubUtils';
import { removeExclamationFromNicknames } from './utils/discord';
await fetchIssues();
await fetchPullRequests();
(async () => {
Logger.info('Removing exclamation marks from nicknames...');
await removeExclamationFromNicknames(config.client.token);
Logger.info('Removing is done!');
})();
await loadCommands();
try {
await registerCommands(config.client.token, config.client.id);
} catch (e) {
console.log(e);
}
const app = new Hono();
app.get('*', (c) => c.redirect('https://www.youtube.com/watch?v=FMhScnY0dME')); // fireship :D
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,
interaction.data.options as any
)
);
}
if (
interaction.type === InteractionType.ApplicationCommand &&
interaction.data.type === ApplicationCommandType.ChatInput
) {
const commands = Commands.get(interaction.data.name);
return await commands.run(new CommandContext(c, commands, interaction));
}
return new CommandContext(c).respond({
type: InteractionResponseType.ChannelMessageWithSource,
data: {
content: 'Beep boop. Boop beep?',
},
});
}); });
app.post('/github_webhook', bodyParse(), (c) => { await client.load();
if (
!c.req.headers.get('User-Agent').startsWith('GitHub-Hookshot/') ||
typeof c.req?.parsedBody !== 'object'
)
return c.redirect('https://www.youtube.com/watch?v=FMhScnY0dME'); // fireship :D
if ( client.login();
!verifyGithubKey(
JSON.stringify(c.req.parsedBody),
c.req.headers.get('X-Hub-Signature-256'),
config.api.github_webhooks_secret
)
)
return c.redirect('https://www.youtube.com/watch?v=FMhScnY0dME'); // fireship :D
const issueOrPr = c.req.parsedBody;
if (issueOrPr.action !== 'deleted') {
if ('issue' in issueOrPr) {
setIssue({
id: issueOrPr.issue.number,
repository: issueOrPr.issue.repository_url.replace(
'https://api.github.com/repos/',
''
),
title: issueOrPr.issue.title,
number: issueOrPr.issue.number,
state: issueOrPr.issue.state,
created_at: issueOrPr.issue.created_at,
closed_at: issueOrPr.issue.closed_at,
html_url: issueOrPr.issue.html_url,
user_login: issueOrPr.issue.user.login,
user_html_url: issueOrPr.issue.user.html_url,
type: '(IS)',
});
} else if ('pull_request' in issueOrPr) {
setPullRequest({
id: issueOrPr.pull_request.number,
repository: issueOrPr.pull_request.html_url
.replace('https://github.com/', '')
.replace(`/pull/${issueOrPr.pull_request.number}`, ''),
title: issueOrPr.pull_request.title,
number: issueOrPr.pull_request.number,
state: issueOrPr.pull_request.state,
created_at: issueOrPr.pull_request.created_at,
closed_at: issueOrPr.pull_request.closed_at,
merged_at: issueOrPr.pull_request.merged_at,
html_url: issueOrPr.pull_request.html_url,
user_login: issueOrPr.pull_request.user.login,
user_html_url: issueOrPr.pull_request.user.html_url,
type: '(PR)',
draft: issueOrPr.pull_request.draft,
});
}
} else {
if ('issue' in issueOrPr)
deleteIssueOrPR(
issueOrPr.issue.number,
issueOrPr.issue.repository_url.replace(
'https://api.github.com/repos/',
''
)
);
else if ('pull_request' in issueOrPr)
deleteIssueOrPR(
issueOrPr.pull_request.number,
issueOrPr.pull_request.html_url
.replace('https://github.com/', '')
.replace(`/pull/${issueOrPr.pull_request.number}`, '')
);
}
return c.json(
{
message: 'OK',
},
200
);
});
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}`);

10
src/listeners/Ready.ts Normal file
View file

@ -0,0 +1,10 @@
import { success } from "@paperdave/logger";
import { Listener } from "../decorators/Listener";
import { ArgsOf } from "../utils";
export class Ready {
@Listener("ready")
onReady([client]: ArgsOf<"ready">) {
success(`Logged in as ${client.user?.tag}`);
}
}

View file

@ -1,20 +0,0 @@
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();

View file

@ -1,64 +0,0 @@
// Taken from https://github.com/Garlic-Team/gcommands/blob/next/src/lib/structures/Command.ts
// @ts-expect-error Types :(
import config from '../../files/config.toml';
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 | Promise<Response>;
runEditResponse?: (ctx: CommandContext) => any;
}
export class Command {
public name: string;
public nameLocalizations?: Record<LocaleString, string>;
public description?: string;
public descriptionLocalizations?: Record<LocaleString, string>;
public guildId?: string = config.client.guild_id;
public defaultMemberPermissions?: string;
public options: Option[] | OptionOptions[];
public run: (ctx: CommandContext) => Response | Promise<Response>;
public runEditResponse: (ctx: CommandContext) => any;
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;
this.runEditResponse = options.runEditResponse;
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()),
}
}
}

View file

@ -1,96 +0,0 @@
// 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 | Promise<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 | Promise<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

@ -1,26 +0,0 @@
import { APIApplicationCommandInteractionDataBasicOption, 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 options?: APIApplicationCommandInteractionDataBasicOption[];
public constructor(c: Context, option: Option | OptionOptions, value: string, options: APIApplicationCommandInteractionDataBasicOption[]) {
this.context = c;
this.option = option;
this.value = value;
this.options = options;
}
public respond(response: APIApplicationCommandOptionChoice[]) {
return this.context.json({
type: InteractionResponseType.ApplicationCommandAutocompleteResult,
data: {
choices: response
}
});
}
}

View file

@ -1,58 +0,0 @@
import { APIApplicationCommandInteraction, APIApplicationCommandInteractionDataOption, APIChatInputApplicationCommandInteraction, APIChatInputApplicationCommandInteractionDataResolved, APIInteractionGuildMember, APIInteractionResponse, APIInteractionResponseCallbackData, APIUser, ApplicationCommandType, InteractionResponseType, InteractionType, RouteBases, Routes } from 'discord-api-types/v10';
import { Context } from 'hono';
import { Command } from '../Command';
// @ts-expect-error Types :(
import config from '../../../files/config.toml';
export class CommandContext {
public context: Context;
public command?: Command;
public interaction?: APIChatInputApplicationCommandInteraction;
public user?: APIUser;
public member?: APIInteractionGuildMember;
public options?: APIApplicationCommandInteractionDataOption[];
public resolved?: APIChatInputApplicationCommandInteractionDataResolved;
public constructor(c: Context, command?: Command, interaction?: APIApplicationCommandInteraction) {
this.context = c;
this.command = command;
if (interaction) {
this.interaction = interaction as APIChatInputApplicationCommandInteraction;
this.user = this.interaction.member.user;
this.member = this.interaction.member;
this.options = this.interaction.data.options;
this.resolved = this.interaction.data.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);
}
public async editResponse(response: APIInteractionResponseCallbackData | string) {
if (typeof response === 'string') {
response = {
content: response
};
}
fetch(`${RouteBases.api}${Routes.webhookMessage(this.interaction.application_id, this.interaction.token)}`, {
method: 'PATCH',
headers: {
'Authorization': `Bot ${config.client.token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(response)
})
}
}

13
src/utils.ts Normal file
View file

@ -0,0 +1,13 @@
import type { ClientEvents } from "discord.js";
export type ArgsOf<K extends keyof ClientEvents> = ClientEvents[K];
export const redactToken = (token: string): string => {
return token
.split(".")
.map((part, index) => {
if (index === 0) return part;
return "*".repeat(part.length);
})
.join(".");
};

View file

@ -1,47 +0,0 @@
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);
}
}

View file

@ -1,46 +0,0 @@
import { Logger } from "./Logger";
export const getDiscordGuildMembers = async(token: string) => {
let oldId;
const result: any[] = [];
while (true) {
const members: any[] = await (await fetch(
`https://discord.com/api/v10/guilds/876711213126520882/members?limit=1000${oldId ? `&after=${oldId}` : ''}`,
{
headers: {
Authorization: `Bot ${token}`,
},
}
)).json();
if (members.length == 0) break;
console.log(members);
result.push(...members.map(m => ({ id: m.id, nickname: m.nick })));
oldId = members[members.length - 1].id;
Logger.debug(`Fetching guild members - ${result.length}, ${oldId}`);
}
Logger.debug(`All guild members has been fetched - ${result.length}`);
return result;
}
export const removeExclamationFromNicknames = async(token: string) => {
for (const member of await getDiscordGuildMembers(token)) {
if (!member.nickname?.startsWith?.('!')) continue;
await fetch(`https://discord.com/api/v8/guilds/876711213126520882/members/${member.id}`, {
method: 'PATCH',
headers: {
Authorization: `Bot ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
nick: member.nickname.slice(1),
}),
});
}
}

View file

@ -1,270 +0,0 @@
// @ts-expect-error Types :(
import config from '../../files/config.toml';
// @ts-expect-error Types :(
import utilities from '../../files/utilities.toml';
import MiniSearch from 'minisearch';
import { Logger } from './Logger';
import { APIApplicationCommandOptionChoice } from 'discord-api-types/v10';
import { Database } from 'bun:sqlite';
import { githubTitleClean } from './regexes';
export type IssueState = 'open' | 'closed' | 'all' | 'merged';
export type IssueType = '(IS)' | '(PR)' | '(IS|PR)';
interface Issue {
id: number;
repository: string;
title: string;
number: number;
state: IssueState,
created_at: string;
closed_at: string | null;
html_url: string;
user_login: string;
user_html_url: string;
type: IssueType;
}
interface PullRequest extends Issue {
merged_at: string | null;
draft: boolean;
}
export const db = new Database('./files/database.sqlite');
await db.exec('DROP TABLE IF EXISTS issuesandprs');
await db.exec('CREATE TABLE issuesandprs (id INTEGER PRIMARY KEY, repository TEXT, title TEXT, number INTEGER, state TEXT, created_at TEXT, closed_at TEXT, merged_at TEXT, html_url TEXT, user_login TEXT, user_html_url TEXT, type TEXT, draft TINYINT)');
const addToDb = db.prepare(
'INSERT INTO issuesandprs (repository, title, number, state, created_at, closed_at, merged_at, html_url, user_login, user_html_url, type, draft) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
);
export let issues: number = 0;
export let pulls: number = 0;
export const fetchIssues = async() => {
for await (const repository of utilities.github.repositories) {
let page = 1;
while (true) {
const res = await (await fetch(`https://api.github.com/repos/${repository}/issues?per_page=100&page=${page}&state=all`, {
headers: {
'Content-Type': 'application/json',
'User-Agent': 'bun-discord-bot',
'Authorization': `token ${config.api.github_personal_access_token}`
}
})).json() as any;
for (const issue of res) {
if ('pull_request' in issue) continue;
// @ts-expect-error it works
await addToDb.run([
issue.repository_url.replace('https://api.github.com/repos/', ''),
issue.title,
issue.number,
issue.state,
issue.created_at,
issue.closed_at,
null,
issue.html_url,
issue.user.login,
issue.user.html_url,
'(IS)',
null,
]);
issues++;
}
Logger.debug(`Fetching issues for ${repository} - ${issues} * ${page}`);
page++;
if (res.length === 0) {
break;
}
}
Logger.success(`Issues have been fetched for ${repository} - ${issues}`);
}
issues = null;
Object.freeze(issues);
}
export const fetchPullRequests = async() => {
for await (const repository of utilities.github.repositories) {
let page = 1;
while (true) {
const res = await (await fetch(`https://api.github.com/repos/${repository}/pulls?per_page=100&page=${page}&state=all`, {
headers: {
'Content-Type': 'application/json',
'User-Agent': 'bun-discord-bot',
'Authorization': `token ${config.api.github_personal_access_token}`
}
})).json() as any;
for (const pull of res) {
// @ts-expect-error it works
await addToDb.run([
pull.html_url.replace('https://github.com/', '').replace(`/pull/${pull.number}`, ''),
pull.title,
pull.number,
pull.state,
pull.created_at,
pull.closed_at,
pull.merged_at,
pull.html_url,
pull.user.login,
pull.user.html_url,
'(PR)',
pull.draft,
]);
pulls++;
}
Logger.debug(`Fetching pull requests for ${repository} - ${pulls} * ${page}`);
page++;
if (res.length === 0) {
break;
}
}
Logger.success(`Pull requests have been fetched for ${repository} - ${pulls}`);
}
pulls = null;
Object.freeze(pulls);
}
export const setIssue = async(issue: Issue) => {
const exists = await db.prepare(`SELECT * FROM issuesandprs WHERE number = ? AND repository = ?`).get(issue.number, issue.repository);
if (exists) {
db.exec(`UPDATE issuesandprs SET state = '${issue.state}', closed_at = '${issue.closed_at}', title = '${issue.title}' WHERE number = ${issue.number} AND repository = '${issue.repository}'`);
} else {
// @ts-expect-error
addToDb.run([
issue.repository,
issue.title,
issue.number,
issue.state,
issue.created_at,
issue.closed_at,
null,
issue.html_url,
issue.user_login,
issue.user_html_url,
'(IS)',
null,
]);
}
}
export const setPullRequest = async(pull: PullRequest) => {
const exists = await db.prepare(`SELECT * FROM issuesandprs WHERE number = ? AND repository = ?`).get(pull.number, pull.repository);
if (exists) {
db.exec(`UPDATE issuesandprs SET state = '${pull.state}', closed_at = '${pull.closed_at}', merged_at = '${pull.merged_at}', title = '${pull.title}' WHERE number = ${pull.number} AND repository = '${pull.repository}'`);
} else {
// @ts-expect-error
addToDb.run([
pull.repository,
pull.title,
pull.number,
pull.state,
pull.created_at,
pull.closed_at,
pull.merged_at,
pull.html_url,
pull.user_login,
pull.user_html_url,
'(IS)',
pull.draft,
]);
}
}
export const deleteIssueOrPR = (number: number, repository: string) => {
db.exec(`DELETE FROM issuesandprs WHERE repository = '${repository}' AND number = ${number}`);
}
export const search = async(query: string, repository: string, state: IssueState, type: IssueType): Promise<APIApplicationCommandOptionChoice[]> => {
try {
const sqliteTypePrepase = type !== '(IS|PR)' ? ` AND type = '${type}'` : '';
const arrayFiltered = state === 'all'
? await db.prepare(`SELECT * FROM issuesandprs WHERE repository = ?${sqliteTypePrepase}`).all(repository)
: state === 'merged'
? await db.prepare(`SELECT * FROM issuesandprs WHERE merged_at IS NOT NULL AND repository = ?${sqliteTypePrepase}`).all(repository)
: await db.prepare(`SELECT * FROM issuesandprs WHERE repository = ? AND state = ?${sqliteTypePrepase}`).all(repository, state);
if (!query) {
const array = arrayFiltered.slice(0, 25);
return array.map((issueOrPr: Issue | PullRequest) => new Object({
name: `${issueOrPr.type.slice(0, -1)} #${issueOrPr.number}) ${formatEmojiStatus(issueOrPr)} ${issueOrPr.title.slice(0, 91 - issueOrPr.id.toString().length).replace(githubTitleClean, '')}`,
value: issueOrPr.number.toString()
})) as APIApplicationCommandOptionChoice[]
}
const searcher = new MiniSearch({
fields: query.startsWith('#') ? ['number'] : ['title'],
storeFields: ['title', 'number', 'type', 'state', 'merged_at', 'draft'],
searchOptions: {
fuzzy: 3,
processTerm: term => term.toLowerCase(),
},
});
searcher.addAll(arrayFiltered);
const result = searcher.search(query);
return (result as unknown as Issue[] | PullRequest[]).slice(0, 25).map((issueOrPr: Issue | PullRequest) => new Object({
name: `${issueOrPr.type.slice(0, -1)} #${issueOrPr.number}) ${formatEmojiStatus(issueOrPr)} ${issueOrPr.title.slice(0, 91 - issueOrPr.id.toString().length).replace(githubTitleClean, '')}`,
value: issueOrPr.number.toString()
})) as APIApplicationCommandOptionChoice[]
} catch(e) {
return [];
}
}
export const getIssueOrPR = async(number: number, repository: string, state: IssueState, type: IssueType): Promise<Issue | PullRequest> => {
const sqliteTypePrepase = type !== '(IS|PR)' ? ` AND type = '${type}'` : '';
const issueOrPR = state === 'all'
? await db.prepare(`SELECT * FROM issuesandprs WHERE repository = ? AND number = ?${sqliteTypePrepase}`).get(repository, number)
: state === 'merged'
? await db.prepare(`SELECT * FROM issuesandprs WHERE repository = ? AND number = ? AND merged_at IS NOT NULL${sqliteTypePrepase}`).get(repository, number)
: await db.prepare(`SELECT * FROM issuesandprs WHERE repository = ? AND number = ? AND state = ?${sqliteTypePrepase}`).get(repository, number, state);
return issueOrPR;
}
export const formatStatus = (data: Issue | PullRequest) => {
let operation = '';
let timestamp = '';
switch(data.state as 'open' | 'closed' | 'all') {
case 'open':
operation = 'opened';
timestamp = `<t:${Math.floor(new Date(data.created_at).getTime() / 1000)}:R>`;
break;
case 'closed':
operation = (data as PullRequest).merged_at ? 'merged' : 'closed';
timestamp = (data as PullRequest).merged_at
? `<t:${Math.floor(new Date((data as PullRequest).merged_at).getTime() / 1000)}:R>`
: `<t:${Math.floor(new Date(data.closed_at).getTime() / 1000)}:R>`;
break;
}
return `${operation} ${timestamp}`;
}
export const formatEmojiStatus = (data: Issue | PullRequest) => {
let emoji = '';
switch(data.state as 'open' | 'closed' | 'all') {
case 'open':
emoji = (data as PullRequest).draft ? '⚫' : '🟢';
break;
case 'closed':
emoji = (data as PullRequest).merged_at ? '🟣' : '🔴';
break;
}
return emoji;
}

View file

@ -1,17 +0,0 @@
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

@ -1 +0,0 @@
export const githubTitleClean = /[`]/gi;

View file

@ -1,56 +0,0 @@
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 have 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()]);
}

View file

@ -1,69 +0,0 @@
import Collection from '@discordjs/collection';
import { APIApplicationCommandOptionChoice } from 'discord-api-types/v10';
// @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)) {
(value as Tag).content = (value as Tag).content.replaceAll('+', '`');
tagCache.set(key, value as unknown as Tag);
}
export const getTag = <T extends boolean>(name: string, more?: T): T extends true ? APIApplicationCommandOptionChoice[] : Tag => {
if (more) {
const exactKeywords: APIApplicationCommandOptionChoice[] = [];
const keywordMatches: APIApplicationCommandOptionChoice[] = [];
const contentMatches: APIApplicationCommandOptionChoice[] = [];
const query = name.toLowerCase();
for (const [tagName, tag] of tagCache.entries()) {
const exactKeyword = tag.keywords.find((t) => t.toLowerCase() === query);
const includesKeyword = tag.keywords.find((t) => t.toLowerCase().includes(query));
const contentMatch = tag.content.toLowerCase().includes(query);
if (exactKeyword) {
exactKeywords.push({
name: `${tagName.replaceAll('-', ' ')}`,
value: tagName
});
} else if (includesKeyword) {
keywordMatches.push({
name: `🔑 ${tagName.replaceAll('-', ' ')}`,
value: tagName
});
} else if (contentMatch) {
contentMatches.push({
name: `📄 ${tagName.replaceAll('-', ' ')}`,
value: tagName
});
}
}
const tags = [...exactKeywords, ...keywordMatches, ...contentMatches];
return tags as T extends true ? APIApplicationCommandOptionChoice[] : Tag;
} else {
const tag = tagCache.get(name) || tagCache.find(tag => tag.keywords.some(k => k.includes(name)));
return tag as T extends true ? APIApplicationCommandOptionChoice[] : Tag;
}
}
export const findTags = (name: string) => {
if (!name)
return [
...tagCache.map((tag, name) => new Object({
name: `🚀 ${name.replaceAll('-', ' ')}`,
value: name
})).slice(0, 25)
];
else {
const tags = getTag(name, true);
if (tags.length > 0) return tags;
else return findTags(null);
}
}

View file

@ -1,104 +0,0 @@
// from https://github.com/discord/discord-interactions-js/blob/main/src/index.ts
import { sign } from 'tweetnacl';
import createHmac from 'create-hmac';
/**
* 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;
}
}
/**
* Validates a payload from GitHub against its signature and secret
*/
export const verifyGithubKey = (
body: string,
signature: string,
secret: string
): boolean => {
if (!body || !signature || !secret) return false;
const githubWebhooksSecret = new TextEncoder().encode(secret);
const sha256 = `sha256=${createHmac('sha256', githubWebhooksSecret).update(body).digest('hex')}`;
if (sha256 !== signature) return false;
return true;
}

View file

@ -4,6 +4,7 @@
"module": "esnext", "module": "esnext",
"target": "esnext", "target": "esnext",
"moduleResolution": "Node", "moduleResolution": "Node",
"allowImportingTsExtensions": true,
// "bun-types" is the important part // "bun-types" is the important part
"types": ["bun-types"] "types": ["bun-types"]
} }