Skip to content

Commit

Permalink
feat: /docs command (#24)
Browse files Browse the repository at this point in the history
  • Loading branch information
tim-smart authored May 25, 2023
1 parent 9d46cc2 commit 48fa051
Show file tree
Hide file tree
Showing 5 changed files with 245 additions and 3 deletions.
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,11 @@
"@effect-http/client": "^0.26.1",
"@effect/data": "^0.12.2",
"@effect/io": "^0.25.13",
"@effect/schema": "^0.19.3",
"@effect/stream": "^0.21.1",
"dfx": "^0.45.9",
"dotenv": "^16.0.3",
"effect-schema-class": "^0.2.4",
"openai": "^3.2.1"
}
}
14 changes: 14 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

222 changes: 222 additions & 0 deletions src/DocsLookup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
import { Discord, Ix } from "dfx"
import {
Data,
Duration,
Effect,
Http,
Layer,
Schedule,
Schema,
SchemaClass,
pipe,
} from "./_common.js"
import { InteractionsRegistry, InteractionsRegistryLive } from "dfx/gateway"

const docUrls = [
"https://effect-ts.github.io/cli",
"https://effect-ts.github.io/data",
"https://effect-ts.github.io/io",
"https://effect-ts.github.io/match",
"https://effect-ts.github.io/rpc",
"https://effect-ts.github.io/schema",
"https://effect-ts.github.io/stm",
"https://effect-ts.github.io/stream",
]

class DocEntry extends SchemaClass({
doc: Schema.string,
title: Schema.string,
content: Schema.string,
url: Schema.string,
relUrl: Schema.string,
}) {
get isSignature() {
return (
this.content.trim().length > 0 &&
this.url.includes("#") &&
!this.title.includes(" overview")
)
}

get subpackage() {
const [, subpackage, suffix] = this.url.match(/github\.io\/(.+?)\/(.+?)\//)!
return suffix !== "modules" && subpackage !== suffix
? `${subpackage}-${suffix}`
: subpackage
}

get package() {
return `@effect/${this.subpackage}`
}

get module() {
return this.doc.replace(/\.ts$/, "")
}

get signature() {
return `${this.module}.${this.title}`
}

get searchTerms(): ReadonlyArray<string> {
const terms: Array<string> = [
`${this.module}.${this.title}`,
`/${this.subpackage}/${this.module}.${this.title}`,
]

const moduleParts = this.module.split("/")
if (moduleParts.length > 1) {
terms.push(`${moduleParts[moduleParts.length - 1]}.${this.title}`)
terms.push(
`/${this.subpackage}/${moduleParts[moduleParts.length - 1]}.${
this.title
}`,
)
}

return terms
}
}

const decodeEntries = Schema.parseEffect(Schema.array(DocEntry.schema()))

class QueryTooShort extends Data.TaggedClass("QueryTooShort")<{
readonly actual: number
readonly min: number
}> {}

const retryPolicy = Schedule.fixed(Duration.seconds(3))

const make = Effect.gen(function* (_) {
const registry = yield* _(InteractionsRegistry)

const buildDocs = (baseUrl: string) =>
Effect.gen(function* (_) {
const searchData = yield* _(
Http.get(`${baseUrl}/assets/js/search-data.json`),
Http.fetchJson(),
Effect.retry(retryPolicy),
Effect.map(_ => Object.values(_ as object)),
Effect.flatMap(_ => decodeEntries(_)),
Effect.map(entries =>
entries
.filter(_ => _.isSignature)
.map(entry =>
entry.copyWith({
url: `${baseUrl}${entry.relUrl}`,
}),
),
),
)

return searchData.flatMap(entry =>
entry.searchTerms.map(term => ({
term,
entry,
})),
)
})

const allDocs = yield* _(
Effect.forEachPar(docUrls, buildDocs),
Effect.map(_ => _.flat()),
Effect.cachedWithTTL(Duration.hours(3)),
)

// prime the cache
yield* _(allDocs)

const search = (query: string) =>
pipe(
Effect.logDebug("searching"),
Effect.zipRight(allDocs),
Effect.map(_ =>
_.map((_, index) => [_, index] as const).filter(([_]) =>
_.term.startsWith(query),
),
),
Effect.logAnnotate("module", "DocsLookup"),
Effect.logAnnotate("query", query),
)

const command = Ix.global(
{
name: "docs",
description: "Search the Effect reference docs",
options: [
{
type: Discord.ApplicationCommandOptionType.STRING,
name: "query",
description: "The query to search for",
required: true,
autocomplete: true,
},
],
},
ix =>
pipe(
Effect.all({
index: ix.optionValue("query"),
docs: allDocs,
}),
Effect.map(({ index, docs }) => {
const entry = docs[Number(index)].entry

return Ix.response({
type: Discord.InteractionCallbackType.CHANNEL_MESSAGE_WITH_SOURCE,
data: {
content: `View the documentation for \`${entry.signature}\` from \`${entry.package}\` here:
${entry.url}`,
},
})
}),
),
)

const autocomplete = Ix.autocomplete(
Ix.option("docs", "query"),
pipe(
Ix.focusedOptionValue,
Effect.filterOrElseWith(
_ => _.length >= 3,
_ => Effect.fail(new QueryTooShort({ actual: _.length, min: 3 })),
),
Effect.flatMap(search),
Effect.map(results =>
Ix.response({
type: Discord.InteractionCallbackType
.APPLICATION_COMMAND_AUTOCOMPLETE_RESULT,
data: {
choices: results.slice(0, 15).map(
([{ entry }, index]): Discord.ApplicationCommandOptionChoice => ({
name: `${entry.signature} (${entry.package})`,
value: index.toString(),
}),
),
},
}),
),
Effect.catchTags({
QueryTooShort: _ =>
Effect.succeed(
Ix.response({
type: Discord.InteractionCallbackType
.APPLICATION_COMMAND_AUTOCOMPLETE_RESULT,
data: { choices: [] },
}),
),
}),
),
)

const ix = Ix.builder
.add(command)
.add(autocomplete)
.catchAllCause(Effect.logErrorCause)

yield* _(registry.register(ix))
})

export const DocsLookupLive = Layer.provide(
InteractionsRegistryLive,
Layer.effectDiscard(make),
)
2 changes: 2 additions & 0 deletions src/_common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,7 @@ export * as ConfigSecret from "@effect/io/Config/Secret"
export * as Effect from "@effect/io/Effect"
export * as Layer from "@effect/io/Layer"
export * as Schedule from "@effect/io/Schedule"
export * as Schema from "@effect/schema/Schema"
export * as Stream from "@effect/stream/Stream"
export { Discord } from "dfx"
export { SchemaClass, SchemaClassExtends } from "effect-schema-class"
8 changes: 5 additions & 3 deletions src/main.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import * as AutoThreads from "bot/AutoThreads"
import * as NoEmbed from "bot/NoEmbed"
import { BotLive } from "bot/Bot"
import { DocsLookupLive } from "bot/DocsLookup"
import { MentionsLive } from "bot/Mentions"
import * as NoEmbed from "bot/NoEmbed"
import * as OpenAI from "bot/OpenAI"
import { SummarizerLive } from "bot/Summarizer"
import { Config, Effect, Layer, pipe } from "bot/_common"
import { Intents } from "dfx"
import { makeLive } from "dfx/gateway"
import * as Dotenv from "dotenv"
import { MentionsLive } from "./Mentions.js"
import { SummarizerLive } from "./Summarizer.js"

Dotenv.config()

Expand Down Expand Up @@ -45,6 +46,7 @@ const MainLive = pipe(
Layer.provide(
Layer.mergeAll(
AutoThreadsLive,
DocsLookupLive,
NoEmbedLive,
MentionsLive,
SummarizerLive,
Expand Down

0 comments on commit 48fa051

Please sign in to comment.