Skip to content

Commit fc535ae

Browse files
committed
feat(commands): expose the client on the command context and pass plugin context to handlers
1 parent 427bc97 commit fc535ae

8 files changed

Lines changed: 71 additions & 20 deletions

File tree

docs/content/commands.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,7 @@ normal text message is available (`ctx.senderId`, `ctx.roomId`, `ctx.isGroup`, `
224224

225225
| Field / Method | Type | Description |
226226
| -------------- | ---- | ----------- |
227+
| `client` | `Client` | The client that received the command. Lets a handler call any client method without importing anything. |
227228
| `command` | `string` | The canonical command name that matched (e.g. `'help'`, `'group kick'`). |
228229
| `args` | `string[]` | Positional arguments after the command name. Flags and the leading command words are removed. |
229230
| `flags` | `Record<string, string \| boolean>` | Parsed `--flag` options. `true` for boolean flags, the string value otherwise. |

docs/content/plugins.mdx

Lines changed: 33 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,8 @@ import { definePlugin } from 'zaileys'
3232
export default definePlugin({
3333
name: 'greet',
3434
description: 'Say hello',
35-
command: async (c) => {
36-
await c.reply('hi there 👋')
35+
command: async (ctx) => {
36+
await ctx.reply('hi there 👋')
3737
},
3838
})
3939
```
@@ -90,8 +90,8 @@ export default definePlugin({
9090
description: 'Say hello',
9191
cooldown: 3,
9292

93-
command: async (c) => {
94-
await c.reply('hi there 👋')
93+
command: async (ctx) => {
94+
await ctx.reply('hi there 👋')
9595
},
9696

9797
message: (m) => {
@@ -142,7 +142,7 @@ import { definePlugin } from 'zaileys'
142142
export default definePlugin({
143143
name: 'my-plugin',
144144
description: 'What it does',
145-
command: async (c) => { /* handles !my-plugin */ },
145+
command: async (ctx) => { /* handles !my-plugin */ },
146146
message: (m) => { /* every inbound message */ },
147147
})
148148
```
@@ -152,7 +152,7 @@ export default definePlugin({
152152
| Field | Type | Description |
153153
| ----- | ---- | ----------- |
154154
| `name` | `string` | Required. Identifies the plugin **and** names the command `command` handles. |
155-
| `command` | `CommandHandler` | Handles the command named after this plugin. Omit if the plugin only listens to events. |
155+
| `command` | `(ctx, plugin) => void \| Promise<void>` | Handles the command named after this plugin. Omit if the plugin only listens to events. |
156156
| `setup` | `(ctx) => void \| (() => void) \| Promise<…>` | Optional escape hatch — see [below](#going-beyond-the-shorthands). |
157157
| `onUnload` | `() => void \| Promise<void>` | Optional cleanup, called on unload. |
158158

@@ -170,7 +170,7 @@ export default definePlugin({
170170
group: true,
171171
admin: true,
172172
cooldown: 3,
173-
command: async (c) => { /* ... */ },
173+
command: async (ctx) => { /* ... */ },
174174
})
175175
```
176176

@@ -209,6 +209,25 @@ Available: `message` · `text` · `image` · `video` · `audio` · `document` ·
209209
Each is typed from [the event map](/events), so the payload needs no annotation. Listeners are
210210
removed automatically when the plugin unloads.
211211

212+
### Reaching the client
213+
214+
A command handler gets the client straight off its context:
215+
216+
```typescript
217+
command: async (ctx) => {
218+
await ctx.client.group.removeMember(ctx.roomId!, ctx.mentions)
219+
},
220+
```
221+
222+
Event payloads are plain message contexts and carry no client, so every handler also receives the
223+
**plugin context** as a second argument:
224+
225+
```typescript
226+
message: async (msg, plugin) => {
227+
if (msg.text === 'ping') await plugin.client.send(msg.roomId!).text('pong')
228+
},
229+
```
230+
212231
## Going beyond the shorthands
213232

214233
`setup(ctx)` is still there for what the fields cannot express: registering **several** commands
@@ -238,12 +257,12 @@ Supports aliases (pipe-separated) and multi-word commands. The command is automa
238257
de-registered when the plugin unloads.
239258

240259
```typescript
241-
ctx.command('ping', async (c) => {
242-
await c.reply('pong')
260+
ctx.command('ping', async (ctx) => {
261+
await ctx.reply('pong')
243262
})
244263

245-
ctx.command('help|h|?', async (c) => {
246-
await c.reply('Available: !ping, !help')
264+
ctx.command('help|h|?', async (ctx) => {
265+
await ctx.reply('Available: !ping, !help')
247266
})
248267
```
249268

@@ -254,7 +273,7 @@ defaults to the plugin's folder**, so `plugins/group/kick.ts` needs no `category
254273
// plugins/group/kick.ts
255274
ctx.command(
256275
{ name: 'kick', description: 'Remove a member', group: true, admin: true },
257-
async (c) => { /* ... */ },
276+
async (ctx) => { /* ... */ },
258277
)
259278
// registered with category: 'group'
260279
```
@@ -335,8 +354,8 @@ export default definePlugin({
335354
const path = require('node:path')
336355
const config = require(path.join(ctx.pluginDir, 'config.json'))
337356

338-
ctx.command('status', async (c) => {
339-
await c.reply(`Mode: ${config.mode}`)
357+
ctx.command('status', async (ctx) => {
358+
await ctx.reply(`Mode: ${config.mode}`)
340359
})
341360
},
342361
})

src/client/client.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -827,6 +827,7 @@ export class Client extends TypedEventEmitter<ClientEventMap> {
827827
let lastSentKey: WAMessageKey | undefined
828828
return {
829829
...msg,
830+
client: this,
830831
raw: resolved.raw,
831832
command: resolved.command,
832833
args: resolved.args,

src/command/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { WAMessageKey } from 'baileys'
22
import type { MessageContext } from '../events/context.js'
33
import type { TextOptions } from '../builder/builder.js'
4+
import type { Client as ZaileysClient } from '../client/client.js'
45

56
export type CommandPrefix = string | string[]
67

@@ -14,6 +15,8 @@ export interface ParsedArgs {
1415
}
1516

1617
export interface CommandContext extends MessageContext {
18+
/** The client that received this command, so a handler needs nothing else in scope. */
19+
client: ZaileysClient
1720
raw: string
1821
command: string
1922
args: string[]

src/plugin/registry.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,13 +55,16 @@ const specOf = (plugin: Plugin): CommandSpec => {
5555

5656
/** Turns the plugin's `command()` and per-event methods into real registrations. */
5757
const wireHandlers = (plugin: Plugin, ctx: PluginContext): void => {
58-
if (typeof plugin.command === 'function') ctx.command(specOf(plugin), plugin.command)
58+
const run = plugin.command
59+
if (typeof run === 'function') {
60+
ctx.command(specOf(plugin), (commandCtx) => run(commandCtx, ctx))
61+
}
5962
const methods = plugin as unknown as Record<string, unknown>
6063
for (const event of INBOUND_EVENTS) {
6164
const handler = methods[camel(event)]
6265
if (typeof handler !== 'function') continue
6366
ctx.on(event, (payload) => {
64-
void (handler as (p: unknown) => unknown)(payload)
67+
void (handler as (p: unknown, c: PluginContext) => unknown)(payload, ctx)
6568
})
6669
}
6770
}

src/plugin/types.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ type Camel<S extends string> = S extends `${infer Head}-${infer Tail}`
3636
export type PluginEventHandlers = {
3737
[E in keyof InboundEventMap as Camel<E>]?: (
3838
payload: InboundEventMap[E],
39+
ctx: PluginContext,
3940
) => void | Promise<void>
4041
}
4142

@@ -75,7 +76,7 @@ export type Plugin = CommandMeta &
7576
name: string
7677
aliases?: string[]
7778
/** Handles the command named after this plugin. The metadata above describes it. */
78-
command?: CommandHandler
79+
command?: (ctx: Parameters<CommandHandler>[0], plugin: PluginContext) => void | Promise<void>
7980
/** Escape hatch for what the methods above cannot express: extra commands, middleware, cleanup. */
8081
setup?(ctx: PluginContext): void | (() => void) | Promise<void | (() => void)>
8182
onUnload?(): void | Promise<void>

tests/client/command-guards.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,3 +171,15 @@ describe('command-not-found', () => {
171171
expect(seen).not.toHaveBeenCalled()
172172
})
173173
})
174+
175+
describe('ctx.client', () => {
176+
it('hands the client to every command handler', async () => {
177+
const client = connected()
178+
let seen: unknown
179+
client.command('who', (ctx) => {
180+
seen = ctx.client
181+
})
182+
await send(client, '!who', false)
183+
expect(seen).toBe(client)
184+
})
185+
})

tests/plugin/handlers.test.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,16 @@ describe('plugin metadata becomes the command spec', () => {
5555
cooldown: 3,
5656
category: 'group',
5757
})
58-
expect(h.commands[0]!.handler).toBe(run)
58+
})
59+
60+
it('hands the plugin context to command() as a second argument', async () => {
61+
const run = vi.fn()
62+
const { host: h } = await load(definePlugin({ name: 'x', command: run }))
63+
;(h.commands[0]!.handler as (c: unknown) => void)({ command: 'x' })
64+
expect(run).toHaveBeenCalledTimes(1)
65+
const [commandCtx, pluginCtx] = run.mock.calls[0] as [unknown, { category?: string }]
66+
expect(commandCtx).toEqual({ command: 'x' })
67+
expect(pluginCtx.category).toBe('tool')
5968
})
6069

6170
it('registers nothing when the plugin has no command handler', async () => {
@@ -83,11 +92,13 @@ describe('event methods', () => {
8392
])
8493
})
8594

86-
it('passes the payload straight to the method', async () => {
95+
it('passes the payload and the plugin context to the method', async () => {
8796
const message = vi.fn()
8897
const { host: h } = await load(definePlugin({ name: 'watcher', message }))
8998
h.listeners[0]!.handler({ text: 'halo' })
90-
expect(message).toHaveBeenCalledWith({ text: 'halo' })
99+
const [payload, pluginCtx] = message.mock.calls[0] as [unknown, { category?: string }]
100+
expect(payload).toEqual({ text: 'halo' })
101+
expect(pluginCtx.category).toBe('tool')
91102
})
92103

93104
it('subscribes to nothing when no event method is present', async () => {

0 commit comments

Comments
 (0)