Skip to content

Commit

Permalink
refactor: better type safety for commands
Browse files Browse the repository at this point in the history
  • Loading branch information
Tim Smart committed Dec 7, 2022
1 parent ef5a128 commit 4393be2
Show file tree
Hide file tree
Showing 5 changed files with 177 additions and 10 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"@tim-smart/discord-api-docs-parser": "^0.4.1",
"@types/ws": "^8.5.3",
"lerna": "^6.1.0",
"ts-toolbelt": "^9.6.0",
"typescript": "https://cdn.jsdelivr.net/npm/@tsplus/installer/compiler/typescript.tgz"
},
"dependencies": {
Expand Down
6 changes: 6 additions & 0 deletions pnpm-lock.yaml

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

20 changes: 18 additions & 2 deletions src/Interactions/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ export class RequiredOptionNotFound {
export const findOption = (name: string) =>
Effect.serviceWith(ApplicationCommandContext)(IxHelpers.getOption(name))

export const requiredOptionValue = (name: string) =>
export const optionValue = (name: string) =>
findOption(name).flatMap((o) =>
o
.flatMapNullable((a) => a.value)
Expand All @@ -130,14 +130,30 @@ export const requiredOptionValue = (name: string) =>
),
)

export const optionValueOptional = (name: string) =>
findOption(name).map((o) => o.flatMapNullable((a) => a.value))

export const subCommandOptionsMap = getSubCommand.map(IxHelpers.optionsMap)

export const findSubCommandOption = (name: string) =>
Effect.serviceWith(SubCommandContext)(({ command }) =>
IxHelpers.getOption(name)(command),
)

export const requiredSubCommandOptionValue = (name: string) =>
export const subCommandOptionValue = (name: string) =>
findSubCommandOption(name).flatMap((o) =>
o
.flatMapNullable((a) => a.value)
.match(
() =>
getSubCommand.flatMap((data) =>
Effect.fail(new RequiredOptionNotFound(data, name)),
),
Effect.succeed,
),
)

export const subCommandOptionValueOptional = (name: string) =>
findSubCommandOption(name).flatMap((o) =>
o
.flatMapNullable((a) => a.value)
Expand Down
144 changes: 137 additions & 7 deletions src/Interactions/definitions.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
import { Effect, EffectTypeId } from "@effect/io/Effect"
import {
RequiredOptionNotFound,
ResolvedDataNotFound,
SubCommandContext,
SubCommandNotFound,
} from "./context.js"
import type { F } from "ts-toolbelt"

type DescriptionMissing<A> = A extends {
type: Exclude<Discord.ApplicationCommandType, 1>
}
Expand All @@ -17,7 +26,7 @@ export class GlobalApplicationCommand<R, E> {
readonly _tag = "GlobalApplicationCommand"
constructor(
readonly command: Discord.CreateGlobalApplicationCommandParams,
readonly handle: Effect<R, E, Discord.InteractionResponse>,
readonly handle: CommandHandler<R, E>,
) {}
}

Expand All @@ -26,21 +35,21 @@ export const global = <
E,
A extends Discord.CreateGlobalApplicationCommandParams,
>(
command: A,
command: F.Narrow<A>,
handle: DescriptionMissing<A> extends true
? "command description is missing"
: Effect<R, E, Discord.InteractionResponse>,
: CommandHandler<R, E, A>,
) =>
new GlobalApplicationCommand<
Exclude<R, Discord.Interaction | Discord.ApplicationCommandDatum>,
E
>(command, handle as any)
>(command as any, handle as any)

export class GuildApplicationCommand<R, E> {
readonly _tag = "GuildApplicationCommand"
constructor(
readonly command: Discord.CreateGuildApplicationCommandParams,
readonly handle: Effect<R, E, Discord.InteractionResponse>,
readonly handle: CommandHandler<R, E>,
) {}
}

Expand All @@ -52,7 +61,7 @@ export const guild = <
command: A,
handle: DescriptionMissing<A> extends true
? "command description is missing"
: Effect<R, E, Discord.InteractionResponse>,
: CommandHandler<R, E, A>,
) =>
new GuildApplicationCommand<
Exclude<R, Discord.Interaction | Discord.ApplicationCommandDatum>,
Expand All @@ -69,7 +78,7 @@ export class MessageComponent<R, E> {

export const messageComponent = <R1, R2, E1, E2>(
pred: (customId: string) => Effect<R1, E1, boolean>,
handle: Effect<R2, E2, Discord.InteractionResponse>,
handle: CommandHandler<R2, E2, Discord.InteractionResponse>,
) =>
new MessageComponent<
Exclude<R1 | R2, Discord.Interaction | Discord.MessageComponentDatum>,
Expand Down Expand Up @@ -120,3 +129,124 @@ export const autocomplete = <R1, R2, E1, E2>(
>,
E1 | E2
>(pred as any, handle as any)

// Command handler helpers
type CommandHandler<R, E, A = any> =
| Effect<R, E, Discord.InteractionResponse>
| CommandHandlerFn<R, E, A>

export interface CommandHelper<A> {
resolve: <T>(
name: Resolvables<A>["name"],
f: (id: Discord.Snowflake, data: Discord.ResolvedDatum) => T | undefined,
) => Effect<Discord.Interaction, ResolvedDataNotFound, T>

option: (
name: CommandOptions<A>["name"],
) => Effect<
Discord.ApplicationCommandDatum,
never,
Maybe<Discord.ApplicationCommandInteractionDataOption>
>

optionValue: (
name: CommandOptions<A>["name"],
) => Effect<Discord.ApplicationCommandDatum, RequiredOptionNotFound, string>

optionValueOptional: (
name: CommandOptions<A>["name"],
) => Effect<Discord.ApplicationCommandDatum, never, Maybe<string>>

subCommandOption: (
name: SubCommandOptions<A>["name"],
) => Effect<
SubCommandContext,
never,
Maybe<Discord.ApplicationCommandInteractionDataOption>
>

subCommandOptionValue: (
name: SubCommandOptions<A>["name"],
) => Effect<SubCommandContext, RequiredOptionNotFound, string>

subCommandOptionValueOptional: (
name: SubCommandOptions<A>["name"],
) => Effect<SubCommandContext, never, Maybe<string>>

subCommands: <
NER extends Record<
SubCommands<A>["name"],
Effect<any, any, Discord.InteractionResponse>
>,
>(
commands: NER,
) => Effect<
| Exclude<
[NER[keyof NER]] extends [
{ [EffectTypeId]: { _R: (_: never) => infer R } },
]
? R
: never,
SubCommandContext
>
| Discord.Interaction
| Discord.ApplicationCommandDatum,
| ([NER[keyof NER]] extends [
{ [EffectTypeId]: { _E: (_: never) => infer E } },
]
? E
: never)
| SubCommandNotFound,
Discord.InteractionResponse
>
}

type CommandHandlerFn<R, E, A> = (
i: CommandHelper<A>,
) => Effect<R, E, Discord.InteractionResponse>

// Extract option names
type ExtractOptions<A, T> = A extends {
name: string
type: T
options?: Discord.ApplicationCommandOption[]
}
?
| A
| (A extends {
options: Discord.ApplicationCommandOption[]
}
? ExtractOptions<A["options"][number], T>
: never)
: A extends {
options: Discord.ApplicationCommandOption[]
}
? ExtractOptions<A["options"][number], T>
: never

type CommandOptions<A> = ExtractOptions<
A,
Exclude<
Discord.ApplicationCommandOptionType,
| Discord.ApplicationCommandOptionType.SUB_COMMAND
| Discord.ApplicationCommandOptionType.SUB_COMMAND_GROUP
>
>

type SubCommands<A> = ExtractOptions<
A,
Discord.ApplicationCommandOptionType.SUB_COMMAND
>

type SubCommandOptions<A> = Exclude<
SubCommands<A>["options"],
undefined
>[number]

type Resolvables<A> = ExtractOptions<
A,
| Discord.ApplicationCommandOptionType.ROLE
| Discord.ApplicationCommandOptionType.USER
| Discord.ApplicationCommandOptionType.MENTIONABLE
| Discord.ApplicationCommandOptionType.CHANNEL
>
16 changes: 15 additions & 1 deletion src/Interactions/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,17 @@ type Handler<R, E> = Effect<
Discord.InteractionResponse
>

const context: D.CommandHelper<any> = {
resolve: Ctx.getResolved,
option: Ctx.findOption,
optionValue: Ctx.optionValue,
optionValueOptional: Ctx.optionValueOptional,
subCommandOption: Ctx.findSubCommandOption,
subCommandOptionValue: Ctx.subCommandOptionValue,
subCommandOptionValueOptional: Ctx.subCommandOptionValueOptional as any,
subCommands: Ctx.handleSubCommands,
} as any

export const handlers = <R, E>(
definitions: D.InteractionDefinition<R, E>[],
): Record<
Expand All @@ -35,7 +46,10 @@ export const handlers = <R, E>(
return pipe(
Maybe.fromNullable(Commands[data.name]).match(
() => Effect.fail(new DefinitionNotFound(i)) as Handler<R, E>,
(command) => command.handle,
(command) =>
Effect.isEffect(command.handle)
? command.handle
: command.handle(context),
),
(a) => a,
Effect.provideService(Ctx.ApplicationCommandContext)(data),
Expand Down

0 comments on commit 4393be2

Please sign in to comment.