Register prefix commands, convert arguments, add guards and cooldowns, and generate help from the same definitions
A prefix command is a message such as !ping or !greet Maya. Fluxerly's command router reads the name after the configured prefix and calls the matching handler. It uses the bot's existing client and does not open another connection. The SDK has no slash commands, buttons or other interaction components, so every command is a message the bot reads
These examples extend the starter bot. Add commands to its existing commands object, or replace its commands option with an example below
Register Commands
Each key of commands becomes a command name. The reply helper accepts a string or full message content and replies to the incoming message
import { runBot } from "@neontechspace/fluxerly"
await runBot({
token: process.env.FLUXER_BOT_TOKEN,
commands: {
prefix: "!",
commands: {
ping: {
description: "Check that the bot can reply",
execute: ({ reply }) => reply("Pong!"),
},
about: {
description: "Describe this bot",
execute: ({ reply }) => reply("Built with Fluxerly"),
},
},
},
})
import { runBot } from "@neontechspace/fluxerly"
await runBot({
token: process.env.FLUXER_BOT_TOKEN,
commands: {
prefix: "!",
commands: {
ping: {
description: "Check that the bot can reply",
execute: ({ reply }) => reply("Pong!"),
},
about: {
description: "Describe this bot",
execute: ({ reply }) => reply("Built with Fluxerly"),
},
},
},
})
Sending !ping now answers Pong!, and !about describes the bot. Returning the reply's Result from execute is enough: If the reply fails, the router reports the failure with the command name without stopping other commands
When Do Command Definitions Fail?
An invalid definition, such as a duplicate name or an unknown argument type, throws Configuration when run is called, before the bot connects.
A command failure happens later, when a matching message arrives. A handler that throws, rejects or returns an Err result has failed, and the router reports it to the on hook, when one is set, or logs it in full at Error
Commands keep the order of the object's own string keys. Omit arguments to receive the raw args without conversion, and use arguments: {} when a command accepts no arguments. The reply helper also forwards the handler's cancellation signal, so stopping the bot cancels a pending reply
Convert Arguments Before Execution
The router converts the words after the command name into typed values before running it. By default it splits the text on whitespace and keeps quote characters, so !greet "Ada Lovelace" arrives as the two words "Ada and Lovelace". The option parse: commands opts in to quoting: Single or double quotes keep words together and are removed from the argument, and a backslash writes the next character literally. A double quote opens a span anywhere. A single quote opens one only at the start of a word and closes it unless a letter or digit follows, so an apostrophe inside a word, as in !say don't, stays literal, 'it's fine' is it's fine and 'Yes', 'No' is Yes, and No. A word that begins with an apostrophe, such as 'twas, opens a span, so write \'twas. This bot uses it to accept !greet "Ada Lovelace" and !remind 10m Stretch
import { commands, runBot } from "@neontechspace/fluxerly"
await runBot({
token: process.env.FLUXER_BOT_TOKEN,
commands: {
prefix: "!",
parse: commands.parseQuoted,
commands: {
greet: {
description: "Greet a name",
arguments: { name: { type: "text" } },
execute: ({ values, reply }) => reply(`Hello, ${values.name}!`),
},
remind: {
description: "Repeat a note after a delay",
arguments: {
delay: { type: "duration", min: 1_000, max: 3_600_000 },
note: { type: "text", rest: true },
},
execute: ({ client, message, values, reply }) => {
client.schedule((signal) => client.messages.reply(message, values.note, { signal }), {
delayMs: values.delay,
})
return reply("Reminder set")
},
},
},
},
})
import { commands, runBot } from "@neontechspace/fluxerly"
await runBot({
token: process.env.FLUXER_BOT_TOKEN,
commands: {
prefix: "!",
parse: commands.parseQuoted,
commands: {
greet: {
description: "Greet a name",
arguments: { name: { type: "text" } },
execute: ({ values, reply }) => reply(`Hello, ${values.name}!`),
},
remind: {
description: "Repeat a note after a delay",
arguments: {
delay: { type: "duration", min: 1_000, max: 3_600_000 },
note: { type: "text", rest: true },
},
execute: ({ client, message, values, reply }) => {
client.schedule((signal) => client.messages.reply(message, values.note, { signal }), {
delayMs: values.delay,
})
return reply("Reminder set")
},
},
},
},
})
The remind command hands the wait to client, so it finishes at once instead of keeping a command slot busy, and the note arrives later as a reply. The bot owns the reminder, so stopping the bot cancels it, and a failed reply goes to the client-level on or the log
In TypeScript, values is a string and values is a number of milliseconds. A missing, invalid or extra argument stops the command before execute, and run answers with the reason and the command's usage, such as Missing name. Usage: !greet <name>. That reply is the on default of run. Set on to send nothing, or pass a function for custom feedback. A router from commands gives no feedback unless on is set
A duration argument reads text such as 90s, 5m or 1h30m and produces milliseconds. Add whole to reject a millisecond part such as 1m500ms, for a value that must be whole seconds, like the duration of a ban. Its default, when given, must then be whole seconds too
Other argument types include:
- Whole and decimal numbers, with
integerandnumberand optionalmin,maxanddefault - One of a fixed list of strings, with
choice - A decimal ID, with
id. Addmention: "user","channel"or"role"to also accept that kind of mention, as in{ type: "id", mention: "user" }. The value is the ID either way - A member of the message's community, with
member, which turns a user ID or mention into a{ guildreferenceId, user Id } - One entry from a fixed list of candidates given at registration, with
user,Choice channelorChoice role. For any user, channel or role, useChoice idormemberinstead - Any other format, with
customand a parse function of the application
Accepting an ID does not mean the user may act on that resource
To convert a single word outside a command, such as a button label or a stored setting, commands applies a descriptor with the same rules as the router. It returns undefined for a rejected word and the descriptor's default for a missing one. A member descriptor also needs { guild as its third parameter
How Are ID and Member Arguments Checked?
An id or member argument accepts a nonzero decimal ID without leading zeroes, up to 9223372036854775807, and the same limit applies to IDs inside mentions. Any other value is rejected as invalid before the command runs. Use a custom argument for other formats.
A member argument produces a { guild reference in the message's community without checking that the user is still a member
Reply to Messages That Do Not Run a Command
A message that starts with the prefix but runs no command is ignored by default. Pass on to tell the sender why. Its second parameter names the cause in _tag:
Commandfor a name that is not registered. ItsUnknown Name suggestionnames the closest registered command when one is similarCommandfor a group such asMissing Subcommand admintyped without a subcommand. Itspathnames the groupCommandwhen the parser could not read the text. WithParser Rejected commands, its.parse Quoted reasonexplains a quote that never closes or a backslash at the end. When the open span held a single quote followed by a letter or digit, as in'a'bor'it's, thereasonalso says that such a quote may have been meant to close the span, so a space after it or double quotes fix the input. Thereasonis absent when the text is not a command at all, such as!alone. Itsnameholds the command name the parser read, as typed, so a reply can say which command the mistake was for
This bot answers a syntax mistake with the parser's own explanation. Without the callback, !greet "Ada is ignored
import { commands, runBot } from "@neontechspace/fluxerly"
await runBot({
token: process.env.FLUXER_BOT_TOKEN,
commands: {
prefix: "!",
parse: commands.parseQuoted,
onUnmatched: ({ reply }, unmatched) => {
if (unmatched._tag !== "CommandParserRejected" || unmatched.reason === undefined)
return undefined
return reply(unmatched.reason)
},
commands: {
greet: {
arguments: { name: { type: "text" } },
execute: ({ values, reply }) => reply(`Hello, ${values.name}!`),
},
},
},
})
import { commands, runBot } from "@neontechspace/fluxerly"
await runBot({
token: process.env.FLUXER_BOT_TOKEN,
commands: {
prefix: "!",
parse: commands.parseQuoted,
onUnmatched: ({ reply }, unmatched) => {
if (unmatched._tag !== "CommandParserRejected" || unmatched.reason === undefined) return undefined
return reply(unmatched.reason)
},
commands: {
greet: {
arguments: { name: { type: "text" } },
execute: ({ values, reply }) => reply(`Hello, ${values.name}!`),
},
},
},
})
The router never replies to these messages by itself. The callback runs for every one of them and anyone can cause that, so an application that replies should limit how often. A channel may also hold other bots with the same prefix, so reply to an unknown name or a syntax mistake only when this bot should answer for that prefix. A custom parse function can give its own explanation by returning { reason: "Separate the words with spaces" } instead of undefined. The reason reaches on unchanged, so it should be fixed text that leaves the sender's input out
To suggest a name in a reply of the application's own, commands applies the rule behind suggestion to any list of names and returns "ping". Pass only names that may be revealed, because the helper does not know which commands are hidden
Each unmatched message is also logged at Debug with the code commands and the fields reason, message, channel and, when there is one, suggestion. The reason field holds the _tag, and for a rejected parse the log message ends with the parser's explanation
Guard, Limit and Wrap Commands
A guard decides whether a matched command may run. A cooldown limits how often each user, channel or community can run it. Middleware wraps every command, for example to measure how long it takes
import { guards, runBot } from "@neontechspace/fluxerly"
await runBot({
token: process.env.FLUXER_BOT_TOKEN,
commands: {
prefix: "!",
mentionPrefix: true,
use: [
async (context, next) => {
const started = Date.now()
await next()
console.info(`${context.name} took ${Date.now() - started} ms`)
},
],
commands: {
purge: {
description: "Delete recent messages",
guard: [guards.guildOnly(), guards.requirePermissions(["ManageMessages"])],
cooldown: { durationMs: 30_000, per: "channel" },
arguments: { count: { type: "integer", min: 1, max: 100, default: 10 } },
execute: ({ values, reply }) => reply(`Would delete ${values.count} messages`),
},
},
},
})
import { guards, runBot } from "@neontechspace/fluxerly"
await runBot({
token: process.env.FLUXER_BOT_TOKEN,
commands: {
prefix: "!",
mentionPrefix: true,
use: [
async (context, next) => {
const started = Date.now()
await next()
console.info(`${context.name} took ${Date.now() - started} ms`)
},
],
commands: {
purge: {
description: "Delete recent messages",
guard: [guards.guildOnly(), guards.requirePermissions(["ManageMessages"])],
cooldown: { durationMs: 30_000, per: "channel" },
arguments: { count: { type: "integer", min: 1, max: 100, default: 10 } },
execute: ({ values, reply }) => reply(`Would delete ${values.count} messages`),
},
},
},
})
Here !purge runs only in a community, for a member with the Manage Messages permission, and at most once every 30 seconds in each channel. With mention, a mention of the bot also works as a prefix
Only messages a person typed can run commands. Messages from bots are skipped by default, and the notices Fluxer posts with text of its own never run commands. For example, a thread named !ping makes Fluxer post a notice containing that name, and it does not run !ping
A guard returns true to allow the command, false to deny it without an automatic reply or { deny: "reason" } to deny it with a reason that on sends. A false verdict stays silent even with the run reply default, but still counts as a rejection, logs commands at Debug and reaches a custom on callback. Each built-in guard denies with a specific reason and covers one common check, allowing only:
- Messages sent in a community, with
guards.guild Only() - Direct and group conversations, with
guards.dm Only() - The owner of the bot's application, with
guards.owner Only() - Listed user IDs, with
guards.owner Only( ids) - Members who have every named permission in the channel, with
guards.require Permissions( names)
How the Built-in Guards Read Data
The require guard decides with permissions and prefer: It reads the community, member, roles and channel from enabled caches first and fetches only what is missing, at most once each per command. Cached values follow the gateway events applied so far, so check again with a fresh permissions before an action where a stale grant matters. The denial names each missing permission by Fluxer's label, such as Manage community. In a thread, it also reads the parent channel the same way and decides from the parent's permissions as Fluxer does, with Send granted exactly when Send is. A failed read fails the command, which is reported with the command name. When Fluxer sent the channel without its permission overwrite list, the guard denies the command because it cannot confirm the channel's permissions. Its decision does not guarantee that Fluxer allows a later action.
The dm guard denies a message that has a guild without a request. Otherwise it confirms a private conversation from the direct-message cache or the channel cache, or reads the channel once. A cached or fetched community channel denies the command. A read that fails for any other reason, such as a network failure or timeout, leaves the channel unconfirmed and fails the command, which is reported with the command name. Enable cache to avoid a read for each command in the same conversation.
The owner guard reads the application's owner from Fluxer on the first command and keeps it for the client's lifetime. A failed read fails the command, and the next command reads again
How Cooldowns Are Stored
A cooldown takes duration and per, which is "user" by default, "channel" or "guild". Without a store, the router keeps cooldowns in memory in this process. That store holds 10,000 keys by default. Set the router's cooldowns: { max option to change the limit, for example cooldowns: { max.
A full store never turns a user away: It removes expired keys first, then the key that would expire soonest, which lets that key run again early. Pass a store to share cooldowns between processes
How Often a Rejected Command Is Answered
So that repeated attempts do not make the bot repeat itself, the "reply" feedback answers an active cooldown once until it expires, and a guard denial with a reason once per user and command every 5 seconds. A guard returning false sends nothing. Argument errors are answered every time.
A skipped answer still counts as a rejection and is logged at Debug. An on function receives every rejection instead, so it can apply its own limit
Control How Commands Run
By default run runs up to eight commands at a time, and when more messages wait than the queue holds, it drops the oldest waiting message with a Warn record. The commands option also accepts the delivery settings of an event handler, concurrency, partition, partition, overflow, max and max, and passes them to the router unchanged. Invalid values throw Configuration when run is called. This bot runs the commands of each channel one at a time in the order they arrived, and stops instead of dropping commands when 64 messages are waiting
import { runBot } from "@neontechspace/fluxerly"
await runBot({
token: process.env.FLUXER_BOT_TOKEN,
commands: {
prefix: "!",
partition: "channel",
maxPendingMessages: 64,
overflow: "stop",
commands: {
ping: { execute: ({ reply }) => reply("Pong!") },
},
},
})
import { runBot } from "@neontechspace/fluxerly"
await runBot({
token: process.env.FLUXER_BOT_TOKEN,
commands: {
prefix: "!",
partition: "channel",
maxPendingMessages: 64,
overflow: "stop",
commands: {
ping: { execute: ({ reply }) => reply("Pong!") },
},
},
})
With overflow: "stop", a full queue stops the bot with Critical, which names the message subscription and the limit it exceeded. Leave overflow unset to keep the bot running and drop waiting messages instead. With a partition, the drops come from the longest backlog, as the events guide describes
A partition can need its own setup before its commands run. With partition, the SDK calls the hook with the key before the first waiting message of that key starts, and the key's messages wait in the queue, using no command slot, until the returned promise settles. Other keys keep running meanwhile. The hook needs partition and otherwise run throws Configuration. A hook that throws, rejects or returns an Err result reports each waiting message of that key to the on of the commands when they have one, otherwise to the client-level on or the log, like a throwing partition function and an overflow that stops the router, and those messages run no command. The events guide covers the timing rules, which are the same for commands
import { runBot } from "@neontechspace/fluxerly"
export function pingAfterSetup(setups) {
return runBot({
token: process.env.FLUXER_BOT_TOKEN,
commands: {
prefix: "!",
partition: "guild",
// A direct message has no community and is keyed by its channel, so an unknown key counts as ready
partitionReady: (key) => setups.get(key),
commands: {
ping: { execute: ({ reply }) => reply("Pong!") },
},
},
})
}
import { runBot } from "@neontechspace/fluxerly"
export function pingAfterSetup(setups: Map<string, Promise<void>>) {
return runBot({
token: process.env.FLUXER_BOT_TOKEN,
commands: {
prefix: "!",
partition: "guild",
// A direct message has no community and is keyed by its channel, so an unknown key counts as ready
partitionReady: (key) => setups.get(key),
commands: {
ping: { execute: ({ reply }) => reply("Pong!") },
},
},
})
}
Use the Effect Entry Point
The Effect API accepts the same options, with partition returning an Effect whose required services become requirements of the program. Each reply stops when the command handler is interrupted and keeps Send in the Effect error channel. Services required by any command remain requirements of the returned program
import { Effect } from "effect"
import { runBot } from "@neontechspace/fluxerly/effect"
export const program = runBot({
token: process.env.FLUXER_BOT_TOKEN,
commands: {
prefix: "!",
commands: {
ping: { execute: ({ reply }) => reply("Pong!").pipe(Effect.asVoid) },
about: { execute: ({ reply }) => reply("Built with Fluxerly").pipe(Effect.asVoid) },
},
},
})
Attach Commands to an Existing Client
To add commands to a client the application already manages, create a router, register commands and attach it to the client. Creation and registration throw Configuration for invalid definitions. Close the returned subscription to detach the router, or wait for its cleanup with wait. The second argument of attach takes the same delivery settings as run, including partition
import { commands } from "@neontechspace/fluxerly"
export function attachCommands(client) {
return commands
.create({ prefix: "!" })
.registerMany({
ping: { execute: ({ reply }) => reply("Pong!") },
about: { execute: ({ reply }) => reply("Built with Fluxerly") },
})
.attach(client)
}
import { commands, type Client } from "@neontechspace/fluxerly"
export function attachCommands(client: Client) {
return commands
.create({ prefix: "!" })
.registerMany({
ping: { execute: ({ reply }) => reply("Pong!") },
about: { execute: ({ reply }) => reply("Built with Fluxerly") },
})
.attach(client)
}
Add Help from the Registered Commands
Each command receives a help function that builds help pages from the registered descriptions and arguments, and a send function that sends them
import { runBot } from "@neontechspace/fluxerly"
await runBot({
token: process.env.FLUXER_BOT_TOKEN,
commands: {
prefix: "!",
commands: {
help: {
description: "List the commands",
execute: ({ sendHelp }) => sendHelp(),
},
ping: {
description: "Check that the bot can reply",
execute: ({ reply }) => reply("Pong!"),
},
},
},
})
import { runBot } from "@neontechspace/fluxerly"
await runBot({
token: process.env.FLUXER_BOT_TOKEN,
commands: {
prefix: "!",
commands: {
help: {
description: "List the commands",
execute: ({ sendHelp }) => sendHelp(),
},
ping: {
description: "Check that the bot can reply",
execute: ({ reply }) => reply("Pong!"),
},
},
},
})
Sending !help lists each command with its description. Each page holds up to 2,000 characters, which fits one message. The send function sends a single page as a plain message, and several pages as reaction pages that the command's author flips with ◀ and ▶. The help function only builds the pages, for a bot that sends them its own way. A router created with commands has the same help method
The paginate function shows any list of pages the same way, as in ({ paginate }) => paginate(. Adding or removing an arrow turns the page, so the bot needs only Add Reactions and Read Message History. The pages stop listening after 60 seconds without a click or 5 minutes in total, and the bot then removes its arrows. Pass users to let other people flip them. Reaction pages explains the options
To keep a command out of help and out of unknown-command suggestions, for example an owner-only tool, register it with hidden: true. Hidden commands still run when someone types their name, so hiding does not restrict access. Protect each command that needs it with a guard
For a larger bot, split command definitions by feature and attach one combined router. Groups such as admin in !admin inspect add structure when needed. The group option of help, such as help(, describes one group. A group registered with hidden: true hides everything inside it, and selecting a hidden group throws Configuration as for a missing one
Wire It Up
A complete bot places the router beside the other run options. This one adds a help command, a per-user cooldown, a larger cooldown store, its own failure hook and a hidden owner-only command
import { guards, runBot } from "@neontechspace/fluxerly"
await runBot({
token: process.env.FLUXER_BOT_TOKEN,
commands: {
prefix: "!",
cooldowns: { maxEntries: 50_000 },
onError: (report) => console.error(report.describe()),
commands: {
help: {
description: "List the commands",
execute: ({ sendHelp }) => sendHelp(),
},
ping: {
description: "Check that the bot can reply",
cooldown: { durationMs: 5_000, per: "user" },
execute: ({ reply }) => reply("Pong!"),
},
uptime: {
hidden: true,
guard: guards.ownerOnly(),
execute: ({ reply }) => reply(`Running for ${Math.round(process.uptime())} s`),
},
},
},
})
import { guards, runBot } from "@neontechspace/fluxerly"
await runBot({
token: process.env.FLUXER_BOT_TOKEN,
commands: {
prefix: "!",
cooldowns: { maxEntries: 50_000 },
onError: (report) => console.error(report.describe()),
commands: {
help: {
description: "List the commands",
execute: ({ sendHelp }) => sendHelp(),
},
ping: {
description: "Check that the bot can reply",
cooldown: { durationMs: 5_000, per: "user" },
execute: ({ reply }) => reply("Pong!"),
},
uptime: {
hidden: true,
guard: guards.ownerOnly(),
execute: ({ reply }) => reply(`Running for ${Math.round(process.uptime())} s`),
},
},
},
})
The owner guard allows only the owner of the bot's application, as Fluxer reports it, so the owner's ID needs no configuration. The commands on hook receives command failures instead of the client-level on, and without either hook the SDK logs each failure in full. If the bot itself stops with a failure, such as a rejected token, run logs it once and sets process to 1