mirror of
https://github.com/xHyroM/bun-discord-bot.git
synced 2024-12-22 12:11:06 +01:00
refactor: rewrite
start working on full rewrite [part 1]
This commit is contained in:
parent
79a90da9a6
commit
a5f1742918
40 changed files with 252 additions and 1511 deletions
47
.github/workflows/validate.yml
vendored
47
.github/workflows/validate.yml
vendored
|
@ -1,34 +1,33 @@
|
|||
name: Validate
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
pull_request_target:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
with:
|
||||
ref: refs/pull/${{ github.event.number }}/merge
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
with:
|
||||
ref: refs/pull/${{ github.event.number }}/merge
|
||||
|
||||
- name: Setup Bun
|
||||
uses: xhyrom/setup-bun@v0.1.3
|
||||
with:
|
||||
bun-version: latest
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v1
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Get changed files
|
||||
uses: Mineflash07/gh-action-get-changed-files@feature/support-pr-target-event
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Get changed files
|
||||
uses: Mineflash07/gh-action-get-changed-files@feature/support-pr-target-event
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Add json
|
||||
run: cp $HOME/files.json ./scripts/validateTags/
|
||||
- name: Update files.json file
|
||||
run: cp $HOME/files.json ./scripts/validate_tags/
|
||||
|
||||
- name: Validate tag
|
||||
run: bun run validate
|
||||
env:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
commit-sha: ${{ github.event.pull_request.head.sha }}
|
||||
pr-number: ${{ github.event.pull_request.number }}
|
||||
- name: Validate tags
|
||||
run: bun run validate:tags
|
||||
env:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
commit-sha: ${{ github.event.pull_request.head.sha }}
|
||||
pr-number: ${{ github.event.pull_request.number }}
|
||||
|
|
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -2,8 +2,6 @@
|
|||
# Edit at https://www.toptal.com/developers/gitignore?templates=node
|
||||
|
||||
files/database.sqlite
|
||||
files/config.toml
|
||||
requests.rest
|
||||
|
||||
### Node ###
|
||||
# Logs
|
||||
|
|
|
@ -1,5 +0,0 @@
|
|||
{
|
||||
"tabWidth": 4,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "es5"
|
||||
}
|
BIN
bun.lockb
BIN
bun.lockb
Binary file not shown.
|
@ -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 = ""
|
|
@ -1,4 +1,3 @@
|
|||
[github]
|
||||
repositories = [
|
||||
"oven-sh/bun",
|
||||
"xHyroM/bun-discord-bot"
|
16
package.json
16
package.json
|
@ -3,19 +3,13 @@
|
|||
"name": "bun-discord-bot",
|
||||
"scripts": {
|
||||
"start": "bun src/index.ts",
|
||||
"validate": "cd scripts/validateTags && bun install && bun start",
|
||||
"cloudflare:tunnel": "sudo cloudflared tunnel run"
|
||||
"validate:tags": "cd scripts/validate_tags && bun install && bun start"
|
||||
},
|
||||
"devDependencies": {
|
||||
"bun-types": "^0.1.8"
|
||||
"bun-types": "^0.7.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@discordjs/collection": "^0.7.0",
|
||||
"bun-utilities": "^0.2.1",
|
||||
"create-hmac": "^1.1.7",
|
||||
"discord-api-types": "^0.36.1",
|
||||
"hono": "^1.6.4",
|
||||
"minisearch": "^5.0.0",
|
||||
"tweetnacl": "^1.0.3"
|
||||
"@paperdave/logger": "^3.0.1",
|
||||
"discord.js": "^14.11.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,3 +0,0 @@
|
|||
[
|
||||
"files/tags.toml"
|
||||
]
|
|
@ -1,6 +0,0 @@
|
|||
{
|
||||
"name": "validate-tags",
|
||||
"scripts": {
|
||||
"start": "bun src/index.ts"
|
||||
}
|
||||
}
|
|
@ -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 { };
|
1
scripts/validate_tags/files.json
Normal file
1
scripts/validate_tags/files.json
Normal file
|
@ -0,0 +1 @@
|
|||
["files/tags.toml"]
|
6
scripts/validate_tags/package.json
Normal file
6
scripts/validate_tags/package.json
Normal file
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"name": "validate_tags",
|
||||
"scripts": {
|
||||
"start": "bun src/index.ts"
|
||||
}
|
||||
}
|
108
scripts/validate_tags/src/index.ts
Normal file
108
scripts/validate_tags/src/index.ts
Normal 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
4
scripts/validate_tags/src/types.d.ts
vendored
Normal file
|
@ -0,0 +1,4 @@
|
|||
declare module '*.toml' {
|
||||
const value: any;
|
||||
export default value;
|
||||
}
|
39
src/Bient.ts
Normal file
39
src/Bient.ts
Normal 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
9
src/commands/Ping.ts
Normal 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!");
|
||||
}
|
||||
}
|
|
@ -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'));
|
||||
}
|
||||
})
|
|
@ -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,
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
|
@ -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')
|
||||
);
|
||||
},
|
||||
});
|
|
@ -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,
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
6
src/decorators/Command.ts
Normal file
6
src/decorators/Command.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
import { ClientEvents } from "discord.js";
|
||||
import { Bient } from "../Bient";
|
||||
|
||||
export function Command(name: string) {
|
||||
return (constructor: Function, context: ClassDecoratorContext) => {};
|
||||
}
|
12
src/decorators/Listener.ts
Normal file
12
src/decorators/Listener.ts
Normal 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])
|
||||
);
|
||||
};
|
||||
}
|
224
src/index.ts
224
src/index.ts
|
@ -1,222 +1,10 @@
|
|||
import { Hono } from 'hono';
|
||||
import { bodyParse } from 'hono/body-parse';
|
||||
import { Logger } from './utils/Logger';
|
||||
import { GatewayIntentBits } from "discord.js";
|
||||
import { Bient } from "./Bient.ts";
|
||||
|
||||
// @ts-expect-error Types :(
|
||||
import config from '../files/config.toml';
|
||||
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?',
|
||||
},
|
||||
});
|
||||
const client = new Bient({
|
||||
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages],
|
||||
});
|
||||
|
||||
app.post('/github_webhook', bodyParse(), (c) => {
|
||||
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
|
||||
await client.load();
|
||||
|
||||
if (
|
||||
!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}`);
|
||||
client.login();
|
||||
|
|
10
src/listeners/Ready.ts
Normal file
10
src/listeners/Ready.ts
Normal 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}`);
|
||||
}
|
||||
}
|
|
@ -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();
|
|
@ -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()),
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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',
|
||||
};
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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
13
src/utils.ts
Normal 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(".");
|
||||
};
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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),
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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`);
|
||||
}
|
||||
}
|
|
@ -1 +0,0 @@
|
|||
export const githubTitleClean = /[`]/gi;
|
|
@ -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()]);
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -1,10 +1,11 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["ESNext"],
|
||||
"module": "esnext",
|
||||
"target": "esnext",
|
||||
"moduleResolution": "Node",
|
||||
// "bun-types" is the important part
|
||||
"types": ["bun-types"]
|
||||
}
|
||||
}
|
||||
"compilerOptions": {
|
||||
"lib": ["ESNext"],
|
||||
"module": "esnext",
|
||||
"target": "esnext",
|
||||
"moduleResolution": "Node",
|
||||
"allowImportingTsExtensions": true,
|
||||
// "bun-types" is the important part
|
||||
"types": ["bun-types"]
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue