Skip to content

Commit 9e98f6a

Browse files
committed
fix(commands): dispatch commands sent as a media caption
1 parent 2a86db8 commit 9e98f6a

7 files changed

Lines changed: 108 additions & 9 deletions

File tree

.changeset/command-on-caption.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
"zaileys": patch
3+
---
4+
5+
Commands now fire when they arrive as a media caption.
6+
7+
The dispatcher listened to the `text` event, which deliberately excludes captions — so a photo
8+
captioned `!sticker` never reached the handler, even though `ctx.text` would have carried it. It now
9+
listens to `message`, which is the same stream with captions included. Plain text commands are
10+
unaffected, and a command still runs exactly once.

docs/content/commands.mdx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,18 @@ omitting the option all leave the command framework disabled — your `command()
3636
never fire.
3737
</Callout>
3838

39-
Commands are matched on the `text` event stream, so they work in DMs, groups, and any chat that
40-
produces a text message. See [Events](/events) for the full event surface.
39+
Commands are matched on every inbound message, in DMs and groups alike — **including media
40+
captions**. Sending a photo captioned `!sticker` runs the `sticker` command with the photo attached
41+
as `ctx.media`, which is how most WhatsApp bots are actually used.
42+
43+
```typescript
44+
client.command('sticker', async (ctx) => {
45+
if (ctx.media?.type !== 'image') return ctx.reply('Send a photo captioned !sticker')
46+
await ctx.send().sticker(await ctx.media.buffer())
47+
})
48+
```
49+
50+
See [Events](/events) for the full event surface.
4151

4252
## Registering a command
4353

src/client/client.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -782,10 +782,14 @@ export class Client extends TypedEventEmitter<ClientEventMap> {
782782
middleware: this.commandMiddleware,
783783
prefixes: this.commandPrefixes,
784784
logger: this.logger,
785+
/**
786+
* `message` rather than `text`: a command is just as often a media caption (`.sticker` on a
787+
* photo), and the `text` event deliberately excludes captions.
788+
*/
785789
onText: (handler) => {
786790
const wrapped = (msg: MessageContext): void => handler(msg)
787-
this.on('text', wrapped)
788-
return () => this.off('text', wrapped)
791+
this.on('message', wrapped)
792+
return () => this.off('message', wrapped)
789793
},
790794
buildContext: (resolved, msg) => this.buildCommandContext(resolved, msg),
791795
isAdmin: (groupJid, senderJid) => this.isGroupAdmin(groupJid, senderJid),

tests/client/command-guards.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ const connected = () => {
3030

3131
/** Fires a text message through the real dispatcher and waits for the async guard chain. */
3232
const send = async (client: Client, text: string, isGroup = true): Promise<void> => {
33-
client.emit('text', msg(text, isGroup))
33+
client.emit('message', msg(text, isGroup))
3434
await new Promise((r) => setTimeout(r, 10))
3535
}
3636

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { describe, expect, it, vi } from 'vitest'
2+
import { Client } from '../../src/client/client.js'
3+
import { MemoryAuthStore } from '../../src/auth/adapters/memory.js'
4+
import { attachInboundPipeline } from '../../src/events/pipeline.js'
5+
import { makeInboundSocket } from '../_helpers/mock-socket-events.js'
6+
7+
const SELF = '628SELF@s.whatsapp.net'
8+
const SENDER = '628111@s.whatsapp.net'
9+
10+
/** Drives the real decode pipeline so the command sees exactly what a live message produces. */
11+
const bot = () => {
12+
const client = new Client({
13+
auth: new MemoryAuthStore(),
14+
qrTerminal: false,
15+
autoConnect: false,
16+
commandPrefix: '!',
17+
})
18+
const socket = makeInboundSocket({ user: { id: SELF } })
19+
;(client as unknown as { _socket: unknown })._socket = socket
20+
attachInboundPipeline(client, socket as unknown as Parameters<typeof attachInboundPipeline>[1], {
21+
selfJid: SELF,
22+
})
23+
return { client, socket }
24+
}
25+
26+
const deliver = async (socket: ReturnType<typeof bot>['socket'], message: unknown) => {
27+
socket.triggerMessagesUpsert({
28+
messages: [{ key: { remoteJid: SENDER, id: 'M1', fromMe: false }, message, messageTimestamp: 1700 }],
29+
type: 'notify',
30+
})
31+
await new Promise((r) => setTimeout(r, 20))
32+
}
33+
34+
describe('a command may arrive as a media caption', () => {
35+
it('runs when the prefix is the caption of an image', async () => {
36+
const { client, socket } = bot()
37+
const run = vi.fn()
38+
client.command('sticker', run)
39+
await deliver(socket, { imageMessage: { mimetype: 'image/jpeg', caption: '!sticker' } })
40+
expect(run).toHaveBeenCalledTimes(1)
41+
})
42+
43+
it('runs for a video caption too, and passes its arguments', async () => {
44+
const { client, socket } = bot()
45+
const run = vi.fn()
46+
client.command('swgc', run)
47+
await deliver(socket, { videoMessage: { mimetype: 'video/mp4', caption: '!swgc promo hari ini' } })
48+
expect(run).toHaveBeenCalledTimes(1)
49+
expect((run.mock.calls[0]![0] as { args: string[] }).args).toEqual(['promo', 'hari', 'ini'])
50+
})
51+
52+
it('still runs for a plain text message', async () => {
53+
const { client, socket } = bot()
54+
const run = vi.fn()
55+
client.command('ping', run)
56+
await deliver(socket, { conversation: '!ping' })
57+
expect(run).toHaveBeenCalledTimes(1)
58+
})
59+
60+
it('runs a command exactly once, not once per event it appears on', async () => {
61+
const { client, socket } = bot()
62+
const run = vi.fn()
63+
client.command('ping', run)
64+
await deliver(socket, { conversation: '!ping' })
65+
expect(run).toHaveBeenCalledTimes(1)
66+
})
67+
68+
it('leaves a captioned image alone when the caption is not a command', async () => {
69+
const { client, socket } = bot()
70+
const run = vi.fn()
71+
client.command('sticker', run)
72+
await deliver(socket, { imageMessage: { mimetype: 'image/jpeg', caption: 'foto liburan' } })
73+
expect(run).not.toHaveBeenCalled()
74+
})
75+
})

tests/client/reply-lifetime.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ describe('replies inherit the disappearing timer', () => {
5454
client.command('ping', async (ctx) => {
5555
await ctx.reply('pong')
5656
})
57-
client.emit('text', msg(604800))
57+
client.emit('message', msg(604800))
5858
await new Promise((r) => setTimeout(r, 20))
5959
expect(optionsOf(sendMessage).ephemeralExpiration).toBe(604800)
6060
})
@@ -64,7 +64,7 @@ describe('replies inherit the disappearing timer', () => {
6464
client.command('ping', async (ctx) => {
6565
await ctx.reply('pong')
6666
})
67-
client.emit('text', msg())
67+
client.emit('message', msg())
6868
await new Promise((r) => setTimeout(r, 20))
6969
expect(optionsOf(sendMessage).ephemeralExpiration).toBeUndefined()
7070
})

tests/plugin/end-to-end.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ describe('plugin file to dispatched command', () => {
6262
{ name: 'ping', aliases: [], description: 'Cek bot', category: 'info' },
6363
])
6464

65-
client.emit('text', msg('!ping'))
65+
client.emit('message', msg('!ping'))
6666
await new Promise((r) => setTimeout(r, 20))
6767
expect((globalThis as Record<string, unknown>)['__hit']).toBe('ping')
6868
})
@@ -88,7 +88,7 @@ describe('plugin file to dispatched command', () => {
8888
)
8989
await boot()
9090

91-
client.emit('text', msg('!legacy'))
91+
client.emit('message', msg('!legacy'))
9292
await new Promise((r) => setTimeout(r, 20))
9393
expect((globalThis as Record<string, unknown>)['__legacy']).toBe(true)
9494
})

0 commit comments

Comments
 (0)