feat: autocomplete for github cmd

This commit is contained in:
xHyroM 2022-07-13 15:41:41 +02:00
parent 3bef855240
commit 73ad04a894
8 changed files with 275 additions and 32 deletions

BIN
bun.lockb

Binary file not shown.

View file

@ -12,4 +12,5 @@ id = "CLIENT USER ID"
guild_id = "GUILD ID"
[api]
github_personal_access_token = ""
github_personal_access_token = ""
github_webhooks_secret = ""

View file

@ -10,8 +10,10 @@
},
"dependencies": {
"@discordjs/collection": "^0.7.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

@ -2,13 +2,10 @@ import { APIApplicationCommandInteractionDataStringOption, ApplicationCommandOpt
import { Command } from '../structures/Command';
// @ts-expect-error Types :(
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 formatStatus from '../utils/formatStatus';
import { CommandContext } from '../structures/contexts/CommandContext';
import { getIssueOrPR, search } from '../utils/githubUtils';
const cooldowns: Collection<string, number> = new Collection();
const invalidIssue = (ctx: CommandContext, query: string) => {
@ -25,7 +22,10 @@ new Command({
name: 'query',
description: 'Issue numer/name, PR number/name or direct link to Github Issue or PR',
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',
@ -64,35 +64,32 @@ new Command({
const repositoryOwner = repositorySplit[0];
const repositoryName = repositorySplit[1];
const isIssueOrPR = githubIssuesAndPullRequests(repositoryOwner, repositoryName).test(query);
const isIssueOrPRNumber = isNumeric(query);
cooldowns.set(ctx.user.id, Date.now() + 30000);
if (!isIssueOrPR && !isIssueOrPRNumber) {
let issueOrPR = getIssueOrPR(parseInt(query), repository);
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);
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([
`[#${data.number} ${repositoryOwner}/${repositoryName}](<${data.html_url}>) by [${data.user.login}](<${data.user.html_url}>) ${formatStatus(data)}`,
data.title
`[#${issueOrPR.number} ${repositoryOwner}/${repositoryName}](<${issueOrPR.html_url}>) by [${issueOrPR.user_login}](<${issueOrPR.user_html_url}>) ${formatStatus(issueOrPR)}`,
issueOrPR.title
].join('\n'));
}
})

View file

@ -12,7 +12,11 @@ import { Commands } from './managers/CommandManager';
import registerCommands from './utils/registerCommands';
import { Option, OptionOptions } from './structures/Option';
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();
try {
await registerCommands(config.client.token, config.client.id);
@ -21,7 +25,7 @@ try {
}
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) => {
const signature = c.req.headers.get('X-Signature-Ed25519');
@ -56,7 +60,8 @@ app.post('/interaction', bodyParse(), async(c) => {
return option.run(new AutocompleteContext(
c,
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({
port: config.server.port,
fetch: app.fetch,

View file

@ -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 { Option, OptionOptions } from '../Option';
@ -6,11 +6,13 @@ 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) {
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[]) {

175
src/utils/githubUtils.ts Normal file
View 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);
}

View file

@ -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;
if (res.status === 429) {