diff --git a/bun.lockb b/bun.lockb index 67f9c49..6ba2f04 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/files/config.example.toml b/files/config.example.toml index 438d780..0b74b28 100644 --- a/files/config.example.toml +++ b/files/config.example.toml @@ -12,4 +12,5 @@ id = "CLIENT USER ID" guild_id = "GUILD ID" [api] -github_personal_access_token = "" \ No newline at end of file +github_personal_access_token = "" +github_webhooks_secret = "" \ No newline at end of file diff --git a/package.json b/package.json index e6cd011..26bc7a1 100644 --- a/package.json +++ b/package.json @@ -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" } } \ No newline at end of file diff --git a/src/commands/github.ts b/src/commands/github.ts index 924d6d3..d13a98b 100644 --- a/src/commands/github.ts +++ b/src/commands/github.ts @@ -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 = 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')); } }) \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 7afef2f..35bd1e6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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, diff --git a/src/structures/contexts/AutocompleteContext.ts b/src/structures/contexts/AutocompleteContext.ts index 1659ace..d500402 100644 --- a/src/structures/contexts/AutocompleteContext.ts +++ b/src/structures/contexts/AutocompleteContext.ts @@ -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[]) { diff --git a/src/utils/githubUtils.ts b/src/utils/githubUtils.ts new file mode 100644 index 0000000..5c289d9 --- /dev/null +++ b/src/utils/githubUtils.ts @@ -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); +} \ No newline at end of file diff --git a/src/utils/registerCommands.ts b/src/utils/registerCommands.ts index 360ec73..c86d94d 100644 --- a/src/utils/registerCommands.ts +++ b/src/utils/registerCommands.ts @@ -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) {