Skip to content

Commit b33c58c

Browse files
committed
feat(commands): add object command specs with metadata, guards, and lifecycle events
1 parent a1f9931 commit b33c58c

16 files changed

Lines changed: 725 additions & 23 deletions

File tree

docs/content/commands.mdx

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,124 @@ client.command('group kick', async (ctx) => {
9898
`/group kick 628123` resolves to `group kick` with `ctx.args === ['628123']`. The leading words
9999
that form the command name are consumed and never appear in `ctx.args`.
100100

101+
### Describing a command
102+
103+
Pass an object instead of a string when a command should carry a description or restrict who may
104+
run it. Both forms register the same way — the string form is just the short one.
105+
106+
```typescript
107+
client.command(
108+
{
109+
name: 'kick',
110+
aliases: ['remove'],
111+
description: 'Remove a member from the group',
112+
usage: '<@user>',
113+
category: 'group',
114+
group: true,
115+
admin: true,
116+
cooldown: 5,
117+
},
118+
async (ctx) => {
119+
await ctx.reply(`Kicking ${ctx.args[0]}`)
120+
},
121+
)
122+
```
123+
124+
| Field | Type | Description |
125+
| ----- | ---- | ----------- |
126+
| `name` | `string` | The command name. Spaces make it a subcommand, exactly as in the string form. |
127+
| `aliases` | `string[]` | Extra names that resolve to the same handler. |
128+
| `description` | `string` | One line explaining the command. Used to build a menu. |
129+
| `usage` | `string` | Argument hint shown beside the name, e.g. `<@user>`. |
130+
| `category` | `string` | Menu grouping. Inside a plugin this defaults to the folder name. |
131+
| `hidden` | `boolean` | Keeps the command callable but leaves it out of `client.commands()` listings. |
132+
| `metadata` | `Record<string, unknown>` | Anything else you want to attach. zaileys never reads it. |
133+
| `group` | `boolean` | Only runs inside a group. |
134+
| `private` | `boolean` | Only runs in a one-to-one chat. |
135+
| `admin` | `boolean` | Only runs for a group admin. Implies `group`. |
136+
| `cooldown` | `number` | Seconds the same sender must wait before reusing this command. |
137+
138+
### Guards and `command-blocked`
139+
140+
When a guard stops a command, zaileys **sends nothing to the chat** — it emits `command-blocked` so
141+
the wording, and the language, stay yours.
142+
143+
```typescript
144+
client.on('command-blocked', async ({ reason, retryIn, ctx }) => {
145+
const message = {
146+
'group-only': 'Perintah ini hanya untuk grup.',
147+
'private-only': 'Perintah ini hanya lewat chat pribadi.',
148+
'admin-only': 'Khusus admin grup.',
149+
cooldown: `Tunggu ${retryIn} detik lagi.`,
150+
}[reason]
151+
await ctx.reply(message)
152+
})
153+
```
154+
155+
| `reason` | Raised when |
156+
| -------- | ----------- |
157+
| `group-only` | The command needs a group (`group: true`, or `admin: true`) but ran in a DM. |
158+
| `private-only` | The command needs a DM but ran in a group. |
159+
| `admin-only` | The sender is not an admin of the group, or the group could not be read. |
160+
| `cooldown` | The same sender reran the command too soon. `retryIn` holds the seconds left. |
161+
162+
<Callout type="info">
163+
A blocked call does **not** start a cooldown, so someone who is refused for being a non-admin is not
164+
also made to wait before their next attempt.
165+
</Callout>
166+
167+
### Catching errors
168+
169+
A handler that throws would otherwise disappear into the log. Listen for `command-error` to tell
170+
the user something went wrong — and to report it wherever you collect errors.
171+
172+
```typescript
173+
client.on('command-error', async ({ command, error, ctx }) => {
174+
console.error(`command ${command} failed`, error)
175+
await ctx.reply('Maaf, terjadi kesalahan. Coba lagi nanti.')
176+
})
177+
```
178+
179+
<Callout type="info">
180+
Listening **takes ownership** of the failure: zaileys stops logging it itself, so the same error is
181+
not reported twice. Without a listener the old behaviour is unchanged — the error is logged and the
182+
bot keeps running.
183+
</Callout>
184+
185+
### Unknown commands
186+
187+
`command-not-found` fires when a message carries a prefix but matches no command. Use it for a
188+
"did you mean" reply, or leave it alone to stay silent.
189+
190+
```typescript
191+
client.on('command-not-found', async ({ command, message }) => {
192+
await message.reply(`Perintah *${command}* tidak ada. Ketik .menu untuk daftar perintah.`)
193+
})
194+
```
195+
196+
<Callout type="warning">
197+
Replying to every unknown command makes a bot noisy in busy groups — any stray `.` message gets an
198+
answer. Consider replying only in private chats, or not at all.
199+
</Callout>
200+
201+
### Listing commands for a menu
202+
203+
`client.commands()` returns every registered command with its metadata — enough to build a help
204+
menu without maintaining a second list by hand.
205+
206+
```typescript
207+
client.command({ name: 'menu', description: 'Show all commands' }, async (ctx) => {
208+
const groups = new Map<string, string[]>()
209+
for (const c of client.commands()) {
210+
if (c.hidden) continue
211+
const line = `.${c.name}${c.usage ? ` ${c.usage}` : ''} — ${c.description ?? ''}`
212+
groups.set(c.category ?? 'other', [...(groups.get(c.category ?? 'other') ?? []), line])
213+
}
214+
const text = [...groups].map(([cat, lines]) => `*${cat}*\n${lines.join('\n')}`).join('\n\n')
215+
await ctx.reply(text)
216+
})
217+
```
218+
101219
## The command context
102220

103221
A `CommandContext` extends the full [message context](/events) — every field and method from a

docs/content/events.mdx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,16 @@ These fire as your session connects, authenticates, and (occasionally) drops. Ev
6363
| `auth-exhausted` | `{ sessionId, kind, attempts, max }` | The [`authGuard`](/configuration#authguard) budget ran out — too many QR / pairing regenerations. `kind` is `'qr' \| 'pairing'`. The client stops and will not auto-retry until you call `connect()` again. |
6464
| `error` | `{ sessionId, error }` | An internal connection error surfaced as an `Error`. |
6565

66+
### Commands
67+
68+
Emitted by the [command framework](/commands). All three are silent no-ops unless you listen.
69+
70+
| Event | Payload | Fires when |
71+
| ----- | ------- | ---------- |
72+
| `command-blocked` | `{ command, reason, retryIn?, ctx }` | A guard stopped a command. `reason` is `'group-only' \| 'private-only' \| 'admin-only' \| 'cooldown'`. zaileys replies nothing — see [Guards](/commands#guards-and-command-blocked). |
73+
| `command-error` | `{ command, error, ctx }` | A command handler threw. Listening takes ownership, so zaileys stops logging it itself. |
74+
| `command-not-found` | `{ command, message }` | A prefixed message matched no registered command. |
75+
6676
### Inbound messages
6777

6878
These deliver a rich [message context](#the-message-context) object — the same shape across every message type. The `media` accessor is populated only for media kinds.

docs/content/plugins.mdx

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,22 @@ ctx.command('help|h|?', async (c) => {
185185
})
186186
```
187187

188+
Pass an object instead to describe the command or guard it. Inside a plugin the **`category`
189+
defaults to the plugin's folder**, so `plugins/group/kick.ts` needs no `category` field:
190+
191+
```typescript
192+
// plugins/group/kick.ts
193+
ctx.command(
194+
{ name: 'kick', description: 'Remove a member', group: true, admin: true },
195+
async (c) => { /* ... */ },
196+
)
197+
// registered with category: 'group'
198+
```
199+
200+
`ctx.category` holds that folder name, or `undefined` for a plugin sitting directly in the plugins
201+
root. Setting `category` yourself always wins. See [Commands](/commands#describing-a-command) for
202+
every field.
203+
188204
See [Commands](/commands) for the full command spec syntax, argument parsing, and `CommandContext`.
189205

190206
### `ctx.use(middleware)`
@@ -237,7 +253,8 @@ leaked listeners, commands, or middleware.
237253
| `client` | `Client` | The full `Client` instance. Call any client method directly from here. |
238254
| `logger` | `Logger \| undefined` | The client's logger instance. May be `undefined` if logging is disabled. |
239255
| `pluginDir` | `string` | Absolute path to the directory containing this plugin file. Useful for loading sibling assets (e.g. JSON config, image files). |
240-
| `command(spec, handler)` | `void` | Register a command. Tracked — auto-removed on unload. |
256+
| `category` | `string \| undefined` | The subfolder this plugin lives in, e.g. `'group'` for `plugins/group/kick.ts`. `undefined` for a plugin in the plugins root. Commands default their `category` to it. |
257+
| `command(spec, handler)` | `void` | Register a command. `spec` is a pipe string or a [command object](/commands#describing-a-command). Tracked — auto-removed on unload. |
241258
| `use(middleware)` | `void` | Register command middleware. Tracked — auto-removed on unload. |
242259
| `on(event, handler)` | `() => void` | Subscribe to a client event. Returns an unsubscribe fn. Tracked — auto-removed on unload. |
243260
| `once(event, handler)` | `() => void` | Like `on` but fires once. Returns an unsubscribe fn. Tracked — auto-removed on unload. |
@@ -378,7 +395,7 @@ import type {
378395
| Type | Shape |
379396
| ---- | ----- |
380397
| `Plugin` | `{ name: string; setup(ctx: PluginContext): void \| (() => void) \| Promise<void \| (() => void)>; onUnload?(): void \| Promise<void> }` |
381-
| `PluginContext` | `{ client, logger, pluginDir, command, use, on, once }` — see table above |
398+
| `PluginContext` | `{ client, logger, pluginDir, category, command, use, on, once }` — see table above |
382399
| `PluginsOptions` | `{ dir?, watch?, pattern?, ignore?, onError? }` — see table above |
383400

384401
`definePlugin` is also exported and is the recommended way to author plugins:

src/client/client.ts

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,10 @@ import {
4040
ZaileysCommandError,
4141
type CommandContext,
4242
type CommandHandler,
43+
type CommandSpec,
4344
type DispatcherHandle,
4445
type Middleware,
46+
type RegisteredCommand,
4547
type ResolvedCommand,
4648
} from '../command/index.js'
4749
import { applyGroupStatusWrap } from '../builder/status-wrap.js'
@@ -738,17 +740,22 @@ export class Client extends TypedEventEmitter<ClientEventMap> {
738740
await this.disconnect()
739741
}
740742

741-
command(spec: string, handler: CommandHandler): this {
743+
command(spec: string | CommandSpec, handler: CommandHandler): this {
742744
;(this.commandRegistry ??= new CommandRegistry()).register(spec, handler)
743745
this.attachCommandsIfReady()
744746
return this
745747
}
746748

747-
unregisterCommand(spec: string): this {
749+
unregisterCommand(spec: string | CommandSpec): this {
748750
this.commandRegistry?.unregister(spec)
749751
return this
750752
}
751753

754+
/** Every registered command with its description and guards — build a help menu from this. */
755+
commands(): RegisteredCommand[] {
756+
return this.commandRegistry?.describe() ?? []
757+
}
758+
752759
use(middleware: Middleware): this {
753760
this.commandMiddleware.push(middleware)
754761
return this
@@ -777,9 +784,45 @@ export class Client extends TypedEventEmitter<ClientEventMap> {
777784
return () => this.off('text', wrapped)
778785
},
779786
buildContext: (resolved, msg) => this.buildCommandContext(resolved, msg),
787+
isAdmin: (groupJid, senderJid) => this.isGroupAdmin(groupJid, senderJid),
788+
onError: (error, ctx) => {
789+
if (this.listenerCount('command-error') === 0) return false
790+
this.emit('command-error', { command: ctx.command, error, ctx })
791+
return true
792+
},
793+
onNotFound: (name, msg) => {
794+
if (this.listenerCount('command-not-found') === 0) return
795+
this.emit('command-not-found', { command: name, message: msg })
796+
},
797+
onBlocked: (block, ctx) => {
798+
this.emit('command-blocked', {
799+
command: ctx.command,
800+
reason: block.reason,
801+
...(block.retryIn !== undefined ? { retryIn: block.retryIn } : {}),
802+
ctx,
803+
})
804+
},
780805
})
781806
}
782807

808+
/** Admin lookup for the `admin` guard. Never throws — an unreachable group means "not an admin". */
809+
private async isGroupAdmin(groupJid: string, senderJid: string): Promise<boolean> {
810+
try {
811+
const meta = await this.group.metadata(groupJid)
812+
const bare = (jid: string): string => jid.split('@')[0]?.split(':')[0] ?? jid
813+
const target = bare(senderJid)
814+
return (meta.participants ?? []).some((p) => {
815+
const entry = p as unknown as Record<string, unknown>
816+
const ids = [p.id, entry['phoneNumber'], entry['jid']].filter(
817+
(v): v is string => typeof v === 'string',
818+
)
819+
return ids.some((id) => bare(id) === target) && p.admin != null
820+
})
821+
} catch {
822+
return false
823+
}
824+
}
825+
783826
private buildCommandContext(resolved: ResolvedCommand, msg: MessageContext): CommandContext {
784827
let lastSentKey: WAMessageKey | undefined
785828
return {

src/client/types.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ import type { OperationGuardOptions } from '../automation/operation-guard.js'
66
import type { PresenceThrottleOptions } from '../automation/presence.js'
77
import type { AuthGuardOptions } from '../connection/auth-guard.js'
88
import type { DisconnectReasonDomain } from '../connection/disconnect-reason.js'
9-
import type { CitationConfig } from '../events/context.js'
9+
import type { CommandBlockedReason, CommandContext } from '../command/types.js'
10+
import type { CitationConfig, MessageContext } from '../events/context.js'
1011
import type { InboundEventMap } from '../events/types.js'
1112
import type { MessageStore } from '../store/types.js'
1213
import type { PluginsOptions } from '../plugin/types.js'
@@ -97,6 +98,18 @@ export type ConnectionEventMap = {
9798
reconnecting: { sessionId: string; attempt: number; delayMs: number; reason: DisconnectReasonDomain }
9899
'auth-exhausted': { sessionId: string; kind: ConnectionAuthType; attempts: number; max: number }
99100
error: { sessionId: string; error: Error }
101+
/** A command matched but a guard stopped it. zaileys sends nothing — reply here if you want to. */
102+
'command-blocked': {
103+
command: string
104+
reason: CommandBlockedReason
105+
/** Seconds until the sender may retry. Only present when `reason` is `cooldown`. */
106+
retryIn?: number
107+
ctx: CommandContext
108+
}
109+
/** A command handler threw. Listening takes ownership: zaileys stops logging it as an error. */
110+
'command-error': { command: string; error: unknown; ctx: CommandContext }
111+
/** A prefixed message matched no command. Only emitted when something is listening. */
112+
'command-not-found': { command: string; message: MessageContext }
100113
/** Cloud provider: delivery lifecycle of outbound messages (sent/delivered/read/failed). */
101114
'message-status': CloudStatusEvent
102115
/** Cloud provider: template review lifecycle (APPROVED/REJECTED/PAUSED...). */

src/command/dispatcher.ts

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Logger } from '../client/types.js'
22
import type { MessageContext } from '../events/context.js'
33
import { ZaileysCommandError } from './errors.js'
4+
import { checkGuards, type GuardBlock } from './guards.js'
45
import { runMiddleware } from './middleware.js'
56
import { parseCommand } from './parser.js'
67
import type { CommandRegistry } from './registry.js'
@@ -21,6 +22,11 @@ export interface DispatcherDeps {
2122
onText: (handler: (msg: MessageContext) => void) => () => void
2223
buildContext: (resolved: ResolvedCommand, msg: MessageContext) => CommandContext
2324
logger: Logger
25+
isAdmin?: (groupJid: string, senderJid: string) => Promise<boolean>
26+
onBlocked?: (block: GuardBlock, ctx: CommandContext) => void
27+
/** Return `true` to claim the failure; otherwise the dispatcher logs it. */
28+
onError?: (error: unknown, ctx: CommandContext) => boolean
29+
onNotFound?: (name: string, msg: MessageContext) => void
2430
}
2531

2632
export interface DispatcherHandle {
@@ -36,7 +42,10 @@ export function attachCommandDispatcher(deps: DispatcherDeps): DispatcherHandle
3642
const parsed = parseCommand(msg.text, deps.prefixes)
3743
if (!parsed.matched) return
3844
const resolution = deps.registry.resolve(parsed)
39-
if (resolution === undefined) return
45+
if (resolution === undefined) {
46+
if (parsed.name !== undefined && parsed.name.length > 0) deps.onNotFound?.(parsed.name, msg)
47+
return
48+
}
4049

4150
const resolved: ResolvedCommand = {
4251
command: resolution.def.name,
@@ -47,13 +56,25 @@ export function attachCommandDispatcher(deps: DispatcherDeps): DispatcherHandle
4756
}
4857
const ctx = deps.buildContext(resolved, msg)
4958

50-
void Promise.resolve(
51-
runMiddleware(deps.middleware, ctx, () => resolution.def.handler(ctx)),
52-
).catch((err) => {
59+
const run = async (): Promise<void> => {
60+
const block = await checkGuards(resolution.def.meta, ctx, {
61+
isAdmin: deps.isAdmin ?? (async () => false),
62+
now: () => Date.now(),
63+
})
64+
if (block !== null) {
65+
deps.onBlocked?.(block, ctx)
66+
return
67+
}
68+
await runMiddleware(deps.middleware, ctx, () => resolution.def.handler(ctx))
69+
}
70+
71+
void run().catch((err) => {
5372
const wrapped =
5473
err instanceof ZaileysCommandError
5574
? err
5675
: new ZaileysCommandError('HANDLER_ERROR', 'command handler failed', { cause: err })
76+
/** A listener takes ownership of the failure; without one it would vanish into the log. */
77+
if (deps.onError?.(wrapped, ctx) === true) return
5778
deps.logger.error(wrapped, 'command dispatch failed')
5879
})
5980
}

src/command/guards.ts

1.88 KB
Binary file not shown.

src/command/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,6 @@ export * from './errors.js'
33
export { parseCommand } from './parser.js'
44
export { CommandRegistry } from './registry.js'
55
export { runMiddleware } from './middleware.js'
6+
export { checkGuards, resetCooldowns, type GuardBlock } from './guards.js'
67
export { attachCommandDispatcher } from './dispatcher.js'
78
export type { DispatcherDeps, DispatcherHandle, ResolvedCommand } from './dispatcher.js'

0 commit comments

Comments
 (0)