Skip to content

Commit 55bc620

Browse files
authored
feat(guide): orama-based guide command (#255)
1 parent 49b75dc commit 55bc620

File tree

8 files changed

+175
-34
lines changed

8 files changed

+175
-34
lines changed
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { InteractionResponseType } from 'discord-api-types/v10';
2+
import type { Response } from 'polka';
3+
import type { OramaSearchResult, OramaSearchResults } from '../../types/orama';
4+
import { AUTOCOMPLETE_MAX_ITEMS, AUTOCOMPLETE_MAX_NAME_LENGTH, DJS_GUIDE_BASE } from '../../util/constants.js';
5+
import { prepareHeader } from '../../util/respond.js';
6+
import { truncate } from '../../util/truncate.js';
7+
8+
function resolveAutocompleteName(element: OramaSearchResult) {
9+
if (element.type === 'page') {
10+
return element.content;
11+
}
12+
13+
if (element.type === 'heading') {
14+
return `# ${element.content}`;
15+
}
16+
17+
return `[...] ${element.content}`;
18+
}
19+
20+
function autocompleteMap(elements: OramaSearchResults) {
21+
return elements
22+
.filter((element) => element.url.length < AUTOCOMPLETE_MAX_NAME_LENGTH)
23+
.map((element) => {
24+
return {
25+
name: truncate(resolveAutocompleteName(element), AUTOCOMPLETE_MAX_NAME_LENGTH),
26+
value: element.url,
27+
};
28+
});
29+
}
30+
31+
export async function oramaAutocomplete(res: Response, query: string) {
32+
const queryUrl = `${DJS_GUIDE_BASE}/api/search?query=${query}`;
33+
const result = (await fetch(queryUrl, {
34+
headers: {
35+
'Content-Type': 'application/json',
36+
},
37+
}).then(async (res) => res.json())) as OramaSearchResults;
38+
39+
prepareHeader(res);
40+
41+
const choices = autocompleteMap(result);
42+
43+
res.write(
44+
JSON.stringify({
45+
data: {
46+
choices: choices.slice(0, AUTOCOMPLETE_MAX_ITEMS - 1),
47+
},
48+
type: InteractionResponseType.ApplicationCommandAutocompleteResult,
49+
}),
50+
);
51+
52+
return res;
53+
}

src/functions/oramaResponse.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { bold, hyperlink } from '@discordjs/builders';
2+
import type { Response } from 'polka';
3+
import { EMOJI_ID_GUIDE } from '../util/constants.js';
4+
import { findRelevantDocsSection } from '../util/discordDocs.js';
5+
import { noCodeLines, resolveResourceFromGuideUrl } from '../util/djsguide.js';
6+
import { prepareResponse } from '../util/respond.js';
7+
import { truncate } from '../util/truncate.js';
8+
9+
type GuideCacheEntry = {
10+
page: string;
11+
timestamp: number;
12+
};
13+
14+
const cache = new Map<string, GuideCacheEntry>();
15+
16+
async function getPage(url: string) {
17+
const cacheEntry = cache.get(url);
18+
19+
if (cacheEntry && cacheEntry.timestamp < Date.now() - 1_000 * 60 * 60) {
20+
return cacheEntry.page;
21+
}
22+
23+
const page = await fetch(url).then(async (res) => res.text());
24+
cache.set(url, { page, timestamp: Date.now() });
25+
26+
return page;
27+
}
28+
29+
export async function oramaResponse(res: Response, resultUrl: string, user?: string, ephemeral?: boolean) {
30+
const parsed = resolveResourceFromGuideUrl(resultUrl);
31+
const contentParts: string[] = [];
32+
33+
const docsContents = await getPage(parsed.githubUrl);
34+
const section = findRelevantDocsSection(parsed.anchor ? `#${parsed.anchor}` : parsed.endpoint ?? '', docsContents);
35+
36+
if (section) {
37+
const title = section.heading?.label ?? parsed.endpoint ?? 'No Title';
38+
contentParts.push(`<:guide:${EMOJI_ID_GUIDE}> ${bold(title)}`);
39+
}
40+
41+
const relevantLines = noCodeLines(section?.lines ?? []);
42+
if (relevantLines.length) {
43+
contentParts.push(truncate(relevantLines.join(' '), 300));
44+
}
45+
46+
contentParts.push(hyperlink('read more', parsed.guideUrl));
47+
48+
prepareResponse(res, contentParts.join('\n'), {
49+
ephemeral,
50+
suggestion: user ? { userId: user, kind: 'guide' } : undefined,
51+
});
52+
return res;
53+
}

src/handling/handleApplicationCommand.ts

Lines changed: 3 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { resolveOptionsToDocsAutoComplete } from '../functions/autocomplete/docs
99
import { djsDocs } from '../functions/docs.js';
1010
import { mdnSearch } from '../functions/mdn.js';
1111
import { nodeAutoCompleteResolve } from '../functions/node.js';
12+
import { oramaResponse } from '../functions/oramaResponse.js';
1213
import type { Tag } from '../functions/tag.js';
1314
import { showTag, reloadTags } from '../functions/tag.js';
1415
import { testTag } from '../functions/testtag.js';
@@ -107,27 +108,8 @@ export async function handleApplicationCommand(
107108
}
108109

109110
case 'guide': {
110-
// const castArgs = args as ArgumentsOf<typeof GuideCommand>;
111-
prepareResponse(
112-
res,
113-
'The guide command is currently unavailable while we rework it to use the new guide page.',
114-
{
115-
ephemeral: true,
116-
},
117-
);
118-
119-
// await algoliaResponse(
120-
// res,
121-
// process.env.DJS_GUIDE_ALGOLIA_APP!,
122-
// process.env.DJS_GUIDE_ALGOLIA_KEY!,
123-
// 'discordjs',
124-
// castArgs.query,
125-
// EMOJI_ID_GUIDE,
126-
// 'guide',
127-
// castArgs.mention,
128-
// castArgs.hide,
129-
// 'guide',
130-
// );
111+
const castArgs = args as ArgumentsOf<typeof GuideCommand>;
112+
await oramaResponse(res, castArgs.query, castArgs.mention, castArgs.hide);
131113
break;
132114
}
133115

src/handling/handleApplicationCommandAutocomplete.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { algoliaAutoComplete } from '../functions/autocomplete/algoliaAutoComple
66
import { djsAutoComplete } from '../functions/autocomplete/docsAutoComplete.js';
77
import { mdnAutoComplete } from '../functions/autocomplete/mdnAutoComplete.js';
88
import { nodeAutoComplete } from '../functions/autocomplete/nodeAutoComplete.js';
9+
import { oramaAutocomplete } from '../functions/autocomplete/oramaAutoComplete.js';
910
import { tagAutoComplete } from '../functions/autocomplete/tagAutoComplete.js';
1011
import type { Tag } from '../functions/tag.js';
1112
import type { DTypesCommand } from '../interactions/discordtypes.js';
@@ -43,13 +44,7 @@ export async function handleApplicationCommandAutocomplete(
4344

4445
case 'guide': {
4546
const args = transformInteraction<typeof GuideCommand>(data.options);
46-
await algoliaAutoComplete(
47-
res,
48-
args.query,
49-
process.env.DJS_GUIDE_ALGOLIA_APP!,
50-
process.env.DJS_GUIDE_ALGOLIA_KEY!,
51-
'discordjs',
52-
);
47+
await oramaAutocomplete(res, args.query);
5348
break;
5449
}
5550

src/types/orama.d.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export type OramaSearchResult = {
2+
content: string;
3+
id: string;
4+
type: 'heading' | 'page' | 'text';
5+
url: string;
6+
};
7+
8+
export type OramaSearchResults = OramaSearchResult[];

src/util/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export const MAX_MESSAGE_LENGTH = 4_000;
3535
export const REMOTE_TAG_URL = 'https://raw.githubusercontent.com/discordjs/discord-utils-bot/main/tags' as const;
3636
export const WEBSITE_URL_ROOT = 'https://discordjs.dev';
3737
export const DJS_DOCS_BASE = 'https://discord.js.org/docs';
38+
export const DJS_GUIDE_BASE = 'https://discordjs.guide' as const;
3839
export const DEFAULT_DOCS_BRANCH = 'stable' as const;
3940
export const VALIDATION_FAIL_COLOR = 0xed4245 as const;
4041
export const VALIDATION_SUCCESS_COLOR = 0x3ba55d as const;

src/util/discordDocs.ts

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,10 @@ export function resolveResourceFromDocsURL(link: string) {
2828
}
2929

3030
type Heading = {
31-
docs_anchor: string;
31+
docs_anchor?: string;
3232
label: string;
33-
route: string;
34-
verb: string;
33+
route?: string;
34+
verb?: string;
3535
};
3636

3737
function parseHeadline(text: string): Heading | null {
@@ -66,7 +66,7 @@ function cleanLine(line: string) {
6666
.trim();
6767
}
6868

69-
const IGNORE_LINE_PREFIXES = ['>', '---', '|'];
69+
const IGNORE_LINE_PREFIXES = ['>', '---', '|', '!'];
7070

7171
export function parseSections(content: string): ParsedSection[] {
7272
const res = [];
@@ -89,6 +89,12 @@ export function parseSections(content: string): ParsedSection[] {
8989
}
9090
}
9191

92+
if (withinPreamble && line.startsWith('title:')) {
93+
const titleName = line.replace('title: ', '');
94+
section.headline = titleName;
95+
section.heading = { label: titleName };
96+
}
97+
9298
index++;
9399

94100
const startsWithIgnorePrefix = IGNORE_LINE_PREFIXES.some((prefix) => line.startsWith(prefix));
@@ -119,6 +125,10 @@ export function parseSections(content: string): ParsedSection[] {
119125
}
120126
}
121127

128+
if (section.heading) {
129+
res.push({ ...section });
130+
}
131+
122132
return res;
123133
}
124134

@@ -140,8 +150,8 @@ function anchorsCompressedEqual(one?: string, other?: string) {
140150
export function findRelevantDocsSection(query: string, docsMd: string) {
141151
const sections = parseSections(docsMd);
142152
for (const section of sections) {
143-
const anchor = section.heading?.docs_anchor;
144-
if (anchor?.startsWith(query) || anchorsCompressedEqual(anchor, query)) {
153+
const anchor = section.heading?.docs_anchor ?? section.headline.toLowerCase();
154+
if (anchor.startsWith(query) || anchorsCompressedEqual(anchor, query)) {
145155
return section;
146156
}
147157
}

src/util/djsguide.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { DJS_GUIDE_BASE } from '../util/constants.js';
2+
3+
export function resolveResourceFromGuideUrl(url: string) {
4+
const anchorSplit = url.split('#');
5+
const withoutAnchor = anchorSplit[0];
6+
const pathParts = withoutAnchor.split('/').slice(1);
7+
const path = pathParts.join('/');
8+
const githubUrl = `https://raw.githubusercontent.com/discordjs/discord.js/main/apps/guide/content/docs/${withoutAnchor}.mdx`;
9+
const guideUrl = `${DJS_GUIDE_BASE}${url}`;
10+
const anchor = anchorSplit.length > 1 ? anchorSplit.slice(1).join('#') : undefined;
11+
12+
return {
13+
githubUrl,
14+
path,
15+
guideUrl,
16+
anchor,
17+
endpoint: pathParts.at(-1),
18+
};
19+
}
20+
21+
export function noCodeLines(lines: string[]) {
22+
const res: string[] = [];
23+
24+
let withinCodeBlock = false;
25+
for (const line of lines) {
26+
if (line.startsWith('```')) {
27+
withinCodeBlock = !withinCodeBlock;
28+
continue;
29+
}
30+
31+
if (withinCodeBlock || line.startsWith('<')) {
32+
continue;
33+
}
34+
35+
res.push(line);
36+
}
37+
38+
return res;
39+
}

0 commit comments

Comments
 (0)