mirror of
https://github.com/xHyroM/bun-discord-bot.git
synced 2024-11-22 14:41:05 +01:00
feat: autocomplete for github cmd
This commit is contained in:
parent
3bef855240
commit
73ad04a894
8 changed files with 275 additions and 32 deletions
BIN
bun.lockb
BIN
bun.lockb
Binary file not shown.
|
@ -13,3 +13,4 @@ guild_id = "GUILD ID"
|
||||||
|
|
||||||
[api]
|
[api]
|
||||||
github_personal_access_token = ""
|
github_personal_access_token = ""
|
||||||
|
github_webhooks_secret = ""
|
|
@ -10,8 +10,10 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@discordjs/collection": "^0.7.0",
|
"@discordjs/collection": "^0.7.0",
|
||||||
|
"create-hmac": "^1.1.7",
|
||||||
"discord-api-types": "^0.36.1",
|
"discord-api-types": "^0.36.1",
|
||||||
"hono": "^1.6.4",
|
"hono": "^1.6.4",
|
||||||
|
"minisearch": "^5.0.0",
|
||||||
"tweetnacl": "^1.0.3"
|
"tweetnacl": "^1.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -2,13 +2,10 @@ import { APIApplicationCommandInteractionDataStringOption, ApplicationCommandOpt
|
||||||
import { Command } from '../structures/Command';
|
import { Command } from '../structures/Command';
|
||||||
// @ts-expect-error Types :(
|
// @ts-expect-error Types :(
|
||||||
import utilities from '../../files/utilities.toml';
|
import utilities from '../../files/utilities.toml';
|
||||||
// @ts-expect-error Types :(
|
|
||||||
import config from '../../files/config.toml';
|
|
||||||
import { githubIssuesAndPullRequests } from '../utils/regexes';
|
|
||||||
import isNumeric from '../utils/isNumeric';
|
|
||||||
import Collection from '@discordjs/collection';
|
import Collection from '@discordjs/collection';
|
||||||
import formatStatus from '../utils/formatStatus';
|
import formatStatus from '../utils/formatStatus';
|
||||||
import { CommandContext } from '../structures/contexts/CommandContext';
|
import { CommandContext } from '../structures/contexts/CommandContext';
|
||||||
|
import { getIssueOrPR, search } from '../utils/githubUtils';
|
||||||
|
|
||||||
const cooldowns: Collection<string, number> = new Collection();
|
const cooldowns: Collection<string, number> = new Collection();
|
||||||
const invalidIssue = (ctx: CommandContext, query: string) => {
|
const invalidIssue = (ctx: CommandContext, query: string) => {
|
||||||
|
@ -25,7 +22,10 @@ new Command({
|
||||||
name: 'query',
|
name: 'query',
|
||||||
description: 'Issue numer/name, PR number/name or direct link to Github Issue or PR',
|
description: 'Issue numer/name, PR number/name or direct link to Github Issue or PR',
|
||||||
type: ApplicationCommandOptionType.String,
|
type: ApplicationCommandOptionType.String,
|
||||||
required: true
|
required: true,
|
||||||
|
run: (ctx) => {
|
||||||
|
return ctx.respond(search(ctx.value, ctx?.options?.[1]?.value as string || 'oven-sh/bun'));
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'repository',
|
name: 'repository',
|
||||||
|
@ -64,35 +64,32 @@ new Command({
|
||||||
const repositoryOwner = repositorySplit[0];
|
const repositoryOwner = repositorySplit[0];
|
||||||
const repositoryName = repositorySplit[1];
|
const repositoryName = repositorySplit[1];
|
||||||
|
|
||||||
const isIssueOrPR = githubIssuesAndPullRequests(repositoryOwner, repositoryName).test(query);
|
let issueOrPR = getIssueOrPR(parseInt(query), repository);
|
||||||
const isIssueOrPRNumber = isNumeric(query);
|
if (!issueOrPR) {
|
||||||
|
|
||||||
cooldowns.set(ctx.user.id, Date.now() + 30000);
|
|
||||||
if (!isIssueOrPR && !isIssueOrPRNumber) {
|
|
||||||
const res = await fetch(`https://api.github.com/search/issues?q=${encodeURIComponent(query)}${encodeURIComponent(' repo:oven-sh/bun')}`);
|
const res = await fetch(`https://api.github.com/search/issues?q=${encodeURIComponent(query)}${encodeURIComponent(' repo:oven-sh/bun')}`);
|
||||||
|
|
||||||
const data: any = await res.json();
|
const data: any = await res.json();
|
||||||
if (data.message || data?.items?.length === 0) return invalidIssue(ctx, query);
|
if (data.message || data?.items?.length === 0) return invalidIssue(ctx, query);
|
||||||
|
|
||||||
query = data.items[0].number;
|
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)' : '(ISSUE)',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const issueUrl = `https://api.github.com/repos/${repositoryOwner}/${repositoryName}/issues/${isIssueOrPR ? query.split('/issues/')[1] : query}`;
|
|
||||||
|
|
||||||
const res = await fetch(issueUrl, {
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'User-Agent': 'bun-discord-bot',
|
|
||||||
'Authorization': config.api.github_personal_access_token
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const data: any = await res.json();
|
|
||||||
if (data.message) return invalidIssue(ctx, query);
|
|
||||||
|
|
||||||
return ctx.editResponse([
|
return ctx.editResponse([
|
||||||
`[#${data.number} ${repositoryOwner}/${repositoryName}](<${data.html_url}>) by [${data.user.login}](<${data.user.html_url}>) ${formatStatus(data)}`,
|
`[#${issueOrPR.number} ${repositoryOwner}/${repositoryName}](<${issueOrPR.html_url}>) by [${issueOrPR.user_login}](<${issueOrPR.user_html_url}>) ${formatStatus(issueOrPR)}`,
|
||||||
data.title
|
issueOrPR.title
|
||||||
].join('\n'));
|
].join('\n'));
|
||||||
}
|
}
|
||||||
})
|
})
|
70
src/index.ts
70
src/index.ts
|
@ -12,7 +12,11 @@ import { Commands } from './managers/CommandManager';
|
||||||
import registerCommands from './utils/registerCommands';
|
import registerCommands from './utils/registerCommands';
|
||||||
import { Option, OptionOptions } from './structures/Option';
|
import { Option, OptionOptions } from './structures/Option';
|
||||||
import { AutocompleteContext } from './structures/contexts/AutocompleteContext';
|
import { AutocompleteContext } from './structures/contexts/AutocompleteContext';
|
||||||
|
import { deleteIssue, deletePullRequest, fetchIssues, fetchPullRequests, issues, setIssue, setPullRequest } from './utils/githubUtils';
|
||||||
|
import createHmac from 'create-hmac';
|
||||||
|
|
||||||
|
await fetchIssues();
|
||||||
|
await fetchPullRequests();
|
||||||
await loadCommands();
|
await loadCommands();
|
||||||
try {
|
try {
|
||||||
await registerCommands(config.client.token, config.client.id);
|
await registerCommands(config.client.token, config.client.id);
|
||||||
|
@ -21,7 +25,7 @@ try {
|
||||||
}
|
}
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
app.get('*', (c) => c.redirect('https://www.youtube.com/watch?v=FMhScnY0dME'));
|
app.get('*', (c) => c.redirect('https://www.youtube.com/watch?v=FMhScnY0dME')); // fireship :D
|
||||||
|
|
||||||
app.post('/interaction', bodyParse(), async(c) => {
|
app.post('/interaction', bodyParse(), async(c) => {
|
||||||
const signature = c.req.headers.get('X-Signature-Ed25519');
|
const signature = c.req.headers.get('X-Signature-Ed25519');
|
||||||
|
@ -56,7 +60,8 @@ app.post('/interaction', bodyParse(), async(c) => {
|
||||||
return option.run(new AutocompleteContext(
|
return option.run(new AutocompleteContext(
|
||||||
c,
|
c,
|
||||||
option,
|
option,
|
||||||
focused.value
|
focused.value,
|
||||||
|
interaction.data.options as any
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -77,6 +82,67 @@ app.post('/interaction', bodyParse(), async(c) => {
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
const githubWebhooksSecret = new TextEncoder().encode(config.api.github_webhooks_secret);
|
||||||
|
const sha256 = `sha256=${createHmac('sha256', githubWebhooksSecret).update(JSON.stringify(c.req.parsedBody)).digest('hex')}`;
|
||||||
|
if (sha256 !== c.req.headers.get('X-Hub-Signature-256')) return c.redirect('https://www.youtube.com/watch?v=FMhScnY0dME'); // fireship :D
|
||||||
|
|
||||||
|
const issueOrPr = c.req.parsedBody;
|
||||||
|
if (issueOrPr.action !== 'deleted') {
|
||||||
|
if (issueOrPr.issue) {
|
||||||
|
console.log('issue');
|
||||||
|
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: new Date(issueOrPr.issue.created_at),
|
||||||
|
closed_at: new Date(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: '(ISSUE)',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
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: new Date(issueOrPr.pull_request.created_at),
|
||||||
|
closed_at: new Date(issueOrPr.pull_request.closed_at),
|
||||||
|
merged_at: new Date(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)',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (issueOrPr.issue) deleteIssue(
|
||||||
|
issueOrPr.issue.number, issueOrPr.issue.repository_url.replace('https://api.github.com/repos/', '')
|
||||||
|
);
|
||||||
|
else deletePullRequest(
|
||||||
|
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({
|
await Bun.serve({
|
||||||
port: config.server.port,
|
port: config.server.port,
|
||||||
fetch: app.fetch,
|
fetch: app.fetch,
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { APIApplicationCommandOptionChoice, InteractionResponseType } from 'discord-api-types/v10';
|
import { APIApplicationCommandInteractionDataBasicOption, APIApplicationCommandOptionChoice, InteractionResponseType } from 'discord-api-types/v10';
|
||||||
import { Context } from 'hono';
|
import { Context } from 'hono';
|
||||||
import { Option, OptionOptions } from '../Option';
|
import { Option, OptionOptions } from '../Option';
|
||||||
|
|
||||||
|
@ -6,11 +6,13 @@ export class AutocompleteContext {
|
||||||
public context: Context;
|
public context: Context;
|
||||||
public option?: Option | OptionOptions;
|
public option?: Option | OptionOptions;
|
||||||
public value?: string;
|
public value?: string;
|
||||||
|
public options?: APIApplicationCommandInteractionDataBasicOption[];
|
||||||
|
|
||||||
public constructor(c: Context, option: Option | OptionOptions, value: string) {
|
public constructor(c: Context, option: Option | OptionOptions, value: string, options: APIApplicationCommandInteractionDataBasicOption[]) {
|
||||||
this.context = c;
|
this.context = c;
|
||||||
this.option = option;
|
this.option = option;
|
||||||
this.value = value;
|
this.value = value;
|
||||||
|
this.options = options;
|
||||||
}
|
}
|
||||||
|
|
||||||
public respond(response: APIApplicationCommandOptionChoice[]) {
|
public respond(response: APIApplicationCommandOptionChoice[]) {
|
||||||
|
|
175
src/utils/githubUtils.ts
Normal file
175
src/utils/githubUtils.ts
Normal file
|
@ -0,0 +1,175 @@
|
||||||
|
// @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';
|
||||||
|
|
||||||
|
interface Issue {
|
||||||
|
id: number;
|
||||||
|
repository: string;
|
||||||
|
title: string;
|
||||||
|
number: number;
|
||||||
|
state: 'open' | 'closed',
|
||||||
|
created_at: Date;
|
||||||
|
closed_at: Date | null;
|
||||||
|
html_url: string;
|
||||||
|
user_login: string;
|
||||||
|
user_html_url: string;
|
||||||
|
type: '(ISSUE)' | '(PR)';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PullRequest extends Issue {
|
||||||
|
merged_at: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export let issues: Issue[] = [];
|
||||||
|
export let pulls: PullRequest[] = [];
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
issues.push({
|
||||||
|
id: issue.number,
|
||||||
|
repository: issue.repository_url.replace('https://api.github.com/repos/', ''),
|
||||||
|
title: issue.title,
|
||||||
|
number: issue.number,
|
||||||
|
state: issue.state,
|
||||||
|
created_at: new Date(issue.created_at),
|
||||||
|
closed_at: new Date(issue.closed_at),
|
||||||
|
html_url: issue.html_url,
|
||||||
|
user_login: issue.user.login,
|
||||||
|
user_html_url: issue.user.html_url,
|
||||||
|
type: '(ISSUE)'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.debug(`Fetching issues for ${repository} - ${issues.length} * ${page}`);
|
||||||
|
|
||||||
|
page++;
|
||||||
|
if (res.length === 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.success(`Issues have been fetched for ${repository} - ${issues.length}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
pulls.push({
|
||||||
|
id: pull.number,
|
||||||
|
repository: pull.html_url.replace('https://github.com/', '').replace(`/pull/${pull.number}`, ''),
|
||||||
|
title: pull.title,
|
||||||
|
number: pull.number,
|
||||||
|
state: pull.state,
|
||||||
|
created_at: new Date(pull.created_at),
|
||||||
|
closed_at: new Date(pull.closed_at),
|
||||||
|
merged_at: new Date(pull.merged_at),
|
||||||
|
html_url: pull.html_url,
|
||||||
|
user_login: pull.user.login,
|
||||||
|
user_html_url: pull.user.html_url,
|
||||||
|
type: '(PR)',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.debug(`Fetching pull requests for ${repository} - ${pulls.length} * ${page}`);
|
||||||
|
|
||||||
|
page++;
|
||||||
|
if (res.length === 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.success(`Pull requests have been fetched for ${repository} - ${pulls.length}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const setIssue = (issue: Issue) => {
|
||||||
|
const exists = issues.findIndex(i => i.number === issue.number && i.repository === issue.repository);
|
||||||
|
if (exists >= 0) issues[exists] = issue;
|
||||||
|
else issues.push(issue);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const setPullRequest = (pull: PullRequest) => {
|
||||||
|
const exists = pulls.findIndex(i => i.number === pull.number);
|
||||||
|
if (exists >= 0) pulls[exists] = pull;
|
||||||
|
else pulls.push(pull);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const deleteIssue = (number: number, repository: string) => {
|
||||||
|
issues = issues.filter(i => i.number === number && i.repository === repository);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const deletePullRequest = (number: number, repository: string) => {
|
||||||
|
pulls = pulls.filter(p => p.number === number && p.repository === repository);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const search = (query: string, repository: string): APIApplicationCommandOptionChoice[] => {
|
||||||
|
try {
|
||||||
|
const pullsFiltered = pulls.filter(pull => pull.repository === repository);
|
||||||
|
const issuesFiltered = issues.filter(issue => issue.repository === repository);
|
||||||
|
|
||||||
|
if (!query) {
|
||||||
|
const array = [].concat(pullsFiltered.slice(0, 13), issuesFiltered.slice(0, 12));
|
||||||
|
return array.map((issueOrPr: Issue | PullRequest) => new Object({
|
||||||
|
name: `${issueOrPr.type} ${issueOrPr.title.slice(0, 93)}`,
|
||||||
|
value: issueOrPr.number.toString()
|
||||||
|
})) as APIApplicationCommandOptionChoice[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const array = [].concat(pullsFiltered, issuesFiltered);
|
||||||
|
|
||||||
|
const searcher = new MiniSearch({
|
||||||
|
fields: ['title', 'number', 'type'],
|
||||||
|
storeFields: ['title', 'number', 'type'],
|
||||||
|
searchOptions: {
|
||||||
|
fuzzy: 3,
|
||||||
|
processTerm: term => term.toLowerCase(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
searcher.addAll(array);
|
||||||
|
|
||||||
|
const result = searcher.search(query);
|
||||||
|
|
||||||
|
return (result as unknown as Issue[] | PullRequest[]).slice(0, 25).map((issueOrPr: Issue | PullRequest) => new Object({
|
||||||
|
name: `${issueOrPr.type} ${issueOrPr.title.slice(0, 93).replace(/[^a-z0-9 ]/gi, '')}`,
|
||||||
|
value: issueOrPr.number.toString()
|
||||||
|
})) as APIApplicationCommandOptionChoice[]
|
||||||
|
} catch(e) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getIssueOrPR = (number: number, repository: string): Issue | PullRequest => {
|
||||||
|
return issues.find(issue => issue.number === number && issue.repository === repository) ||
|
||||||
|
pulls.find(pull => pull.number === number && pull.repository === repository);
|
||||||
|
}
|
|
@ -21,7 +21,7 @@ const sync = async(
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
if (res.ok) return Logger.success('🌍 All commands has been synchronized with discord api.');
|
if (res.ok) return Logger.success('🌍 All commands have been synchronized with discord api.');
|
||||||
const data = await res.json() as any;
|
const data = await res.json() as any;
|
||||||
|
|
||||||
if (res.status === 429) {
|
if (res.status === 429) {
|
||||||
|
|
Loading…
Reference in a new issue