Dispatcher
local Operator = require(game:GetService("ServerStorage").Operator)
local Dispatcher = Operator.DispatcherTurns raw text into a run command. It takes the registry as a parameter rather than requiring it, so it has no opinion about where commands came from.
Functions
Dispatcher.new
Dispatcher.new(registry: Registry, options: Options?): Dispatcherlocal dispatcher = Dispatcher.new(registry, {
logger = Log.new(function(level, message) print(level, message) end),
confirmHandler = confirm,
guardTimeout = 5,
})Methods
:run
dispatcher:run(text: string, executor: Player?): ResultTokenizes, parses, binds, transforms, runs the guards, then runs the handler. Omit executor when the server itself is running the command.
Path resolution prefers the longest match, so registering both a and a b means a b thing runs a b with thing as its argument.
local result = dispatcher:run("kick @me being rude", player)
if not result.ok then
warn(result.message)
end:addGuard
dispatcher:addGuard(guard: GuardFn, pathPrefix: { string }?): ()Adds a guard. With no prefix it is global; with { "admin" } it applies to every command under that group.
Global guards run first, in registration order, then the command's own :guard(...) chain in declaration order.
dispatcher:addGuard(function(context)
if context.executor == nil then
return true
end
return false, "moderators only"
end, { "admin" })Guards fail closed
A guard that raises or exceeds guardTimeout denies the command rather than letting it through, and the real reason is logged.
:setConfirmHandler
dispatcher:setConfirmHandler(handler: ConfirmHandler?): ()Installs the handler that context:confirm and :destructive() call. Pass nil to remove it. Destructive commands are then cancelled rather than executed.
Types
Options
type Options = {
logger: Log.Logger?,
confirmHandler: ConfirmHandler?,
guardTimeout: number?,
authorize: Authorize?,
audit: Audit?,
actionLog: ActionLog?,
}| Field | Default | Meaning |
|---|---|---|
logger | silent | a Logger |
confirmHandler | none | answers destructive confirmations |
guardTimeout | 5 | seconds before a yielding guard is denied |
authorize | none | (executor, command) -> boolean, usually roles:canRun |
audit | none | an Audit to record every dispatch into |
actionLog | none | an ActionLog for moderation actions from context:logAction |
Result
type Result = {
ok: boolean,
status: Status,
message: string?,
path: { string },
jobId: string,
replies: { string },
diagnostics: { any },
}| Field | Meaning |
|---|---|
ok | whether the command ran and reported no error |
status | one of the Status values |
message | the first reply, or the reason it failed |
path | the resolved command path |
jobId | the server's game.JobId, always server-generated |
replies | every message the handler sent |
diagnostics | parse and binding diagnostics, for inline validation in a UI |
Status
Dispatcher.Status = {
Ok = "Ok",
NotFound = "NotFound",
Invalid = "Invalid",
Denied = "Denied",
Cancelled = "Cancelled",
Failed = "Failed",
Errored = "Errored",
ExecutorLeft = "ExecutorLeft",
}| Status | When |
|---|---|
Ok | the handler ran and reported no error |
NotFound | no command matched the input |
Invalid | the input failed to parse, bind, or transform |
Denied | a guard refused |
Cancelled | a destructive command was not confirmed |
Failed | the handler called context:error(...) |
Errored | the handler raised |
ExecutorLeft | the player left partway through |
A handler that raises is caught: the caller gets a generic the command failed and the real error goes to the logger, never to the player.
NotFound, not Denied, over the wire
A command the player is not allowed to run comes back from the transport as NotFound, so probing the remote with guessed names cannot enumerate commands the player was never shown. A direct dispatcher:run on the server reports Denied normally.
Authorize
type Authorize = (executor: Player, command: Command) -> boolean