Skip to content

Commit ad21c31

Browse files
committed
fix(plugins): load plugins that declare handlers without a setup function
1 parent e0791bd commit ad21c31

2 files changed

Lines changed: 105 additions & 4 deletions

File tree

src/plugin/loader.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,8 @@ export async function importPlugin(file: string, bust?: number): Promise<Plugin
3737
const url = pathToFileURL(file).href + (bust !== undefined ? `?t=${bust}` : '')
3838
const mod = (await import(url)) as { default?: Plugin } & Partial<Plugin>
3939
const candidate = mod.default ?? (mod as Plugin)
40-
if (candidate && typeof candidate.name === 'string' && typeof candidate.setup === 'function') {
41-
return candidate
42-
}
43-
return undefined
40+
/** A plugin may be purely declarative, so a name is all that is required here. */
41+
return candidate && typeof candidate.name === 'string' ? candidate : undefined
4442
} catch {
4543
return undefined
4644
}

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

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { promises as fs } from 'node:fs'
2+
import os from 'node:os'
3+
import path from 'node:path'
4+
import { randomBytes } from 'node:crypto'
5+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
6+
import { Client } from '../../src/client/client.js'
7+
import { MemoryAuthStore } from '../../src/auth/adapters/memory.js'
8+
import { PluginLoader } from '../../src/plugin/loader.js'
9+
import { PluginRegistry } from '../../src/plugin/registry.js'
10+
import type { PluginHost } from '../../src/plugin/registry.js'
11+
import type { MessageContext } from '../../src/events/context.js'
12+
13+
const SENDER = '628111@s.whatsapp.net'
14+
15+
const msg = (text: string): MessageContext =>
16+
({
17+
text,
18+
senderId: SENDER,
19+
roomId: SENDER,
20+
isGroup: false,
21+
message: () => ({ key: { remoteJid: SENDER, id: 'M1', fromMe: false } }),
22+
}) as unknown as MessageContext
23+
24+
/** Drives the real loader over real files against a real Client — the exact path a bot takes. */
25+
describe('plugin file to dispatched command', () => {
26+
let dir: string
27+
let client: Client
28+
29+
beforeEach(async () => {
30+
dir = path.join(os.tmpdir(), `zaileys-e2e-${randomBytes(6).toString('hex')}`)
31+
await fs.mkdir(path.join(dir, 'info'), { recursive: true })
32+
client = new Client({
33+
auth: new MemoryAuthStore(),
34+
qrTerminal: false,
35+
autoConnect: false,
36+
commandPrefix: '!',
37+
})
38+
;(client as unknown as { _socket: unknown })._socket = { user: { id: 'me@s.whatsapp.net' } }
39+
})
40+
afterEach(async () => {
41+
await fs.rm(dir, { recursive: true, force: true })
42+
})
43+
44+
const boot = async (): Promise<void> => {
45+
const registry = new PluginRegistry({ client: client as unknown as PluginHost })
46+
const loader = new PluginLoader({ registry, options: { dir, watch: false } })
47+
await loader.start()
48+
}
49+
50+
it('registers a declarative command and dispatches it', async () => {
51+
await fs.writeFile(
52+
path.join(dir, 'info', 'ping.js'),
53+
`export default {
54+
name: 'ping',
55+
description: 'Cek bot',
56+
command: async (ctx) => { globalThis.__hit = ctx.command },
57+
}`,
58+
)
59+
await boot()
60+
61+
expect(client.commands()).toEqual([
62+
{ name: 'ping', aliases: [], description: 'Cek bot', category: 'info' },
63+
])
64+
65+
client.emit('text', msg('!ping'))
66+
await new Promise((r) => setTimeout(r, 20))
67+
expect((globalThis as Record<string, unknown>)['__hit']).toBe('ping')
68+
})
69+
70+
it('wires a declarative event method', async () => {
71+
await fs.writeFile(
72+
path.join(dir, 'info', 'watch.js'),
73+
`export default { name: 'watch', message: (m) => { globalThis.__seen = m.text } }`,
74+
)
75+
await boot()
76+
77+
client.emit('message', msg('halo'))
78+
expect((globalThis as Record<string, unknown>)['__seen']).toBe('halo')
79+
})
80+
81+
it('still supports a setup-based plugin', async () => {
82+
await fs.writeFile(
83+
path.join(dir, 'info', 'old.js'),
84+
`export default {
85+
name: 'old',
86+
setup(ctx) { ctx.command('legacy', () => { globalThis.__legacy = true }) },
87+
}`,
88+
)
89+
await boot()
90+
91+
client.emit('text', msg('!legacy'))
92+
await new Promise((r) => setTimeout(r, 20))
93+
expect((globalThis as Record<string, unknown>)['__legacy']).toBe(true)
94+
})
95+
96+
it('reports a plugin that fails to import instead of dying', async () => {
97+
const onError = vi.fn()
98+
await fs.writeFile(path.join(dir, 'info', 'broken.js'), 'this is not valid js {{{')
99+
const registry = new PluginRegistry({ client: client as unknown as PluginHost })
100+
await new PluginLoader({ registry, options: { dir, watch: false, onError } }).start()
101+
expect(onError).toHaveBeenCalled()
102+
})
103+
})

0 commit comments

Comments
 (0)