Skip to content

Commit 0d321a8

Browse files
committed
feat(client): autoRejectCall option and client.rejectCall() with web-only guard
1 parent b3f97cb commit 0d321a8

3 files changed

Lines changed: 187 additions & 1 deletion

File tree

src/client/client.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,23 +47,27 @@ import { PluginRegistry, PluginLoader, type PluginHost } from '../plugin/index.j
4747
import type { PluginsOptions } from '../plugin/types.js'
4848
import {
4949
AutoDeleteSweeper,
50+
AutoRejectCallModule,
5051
createOperationGuard,
5152
PresenceModule,
5253
RateLimiter,
5354
runBroadcast,
5455
Scheduler,
5556
type AutoDeleteOptions,
57+
type AutoRejectCallOptions,
5658
type AutomationSocketLike,
59+
type CallSocketLike,
5760
type BroadcastOptions,
5861
type BroadcastResult,
5962
type OperationGuard,
6063
type PresenceThrottleOptions,
6164
type ScheduleHandle,
6265
type ScheduledContentSnapshot,
66+
ZaileysAutomationError,
6367
} from '../automation/index.js'
6468
import type { CitationConfig, MessageContext } from '../events/context.js'
6569
import { createDownloadFn } from '../events/decoders/_media-download.js'
66-
import type { MediaDownloadResult, MediaKind } from '../events/types.js'
70+
import type { CallPayload, MediaDownloadResult, MediaKind } from '../events/types.js'
6771
import {
6872
formatConnectionStatus,
6973
suppressLibsignalNoise,
@@ -185,6 +189,7 @@ export class Client extends TypedEventEmitter<ClientEventMap> {
185189
private _presence?: PresenceModule
186190
private _scheduler?: Scheduler
187191
private readonly autoDeleteOptions: AutoDeleteOptions | undefined
192+
private readonly autoRejectCall: AutoRejectCallModule
188193
private autoDeleteSweeper: AutoDeleteSweeper | undefined
189194
private readonly pluginsOptions: PluginsOptions | undefined
190195
private pluginRegistry: PluginRegistry | undefined
@@ -222,6 +227,18 @@ export class Client extends TypedEventEmitter<ClientEventMap> {
222227
this.commandPrefixes = normalizePrefixes(options.commandPrefix)
223228
this.citationConfig = options.citation
224229
this.ignoreMe = options.ignoreMe ?? true
230+
this.autoRejectCall = new AutoRejectCallModule(
231+
() => this._socket as unknown as CallSocketLike | undefined,
232+
options.autoRejectCall === true
233+
? { enabled: true }
234+
: options.autoRejectCall === false || options.autoRejectCall === undefined
235+
? {}
236+
: options.autoRejectCall,
237+
this.logger,
238+
)
239+
if (this._provider === 'baileys' && this.autoRejectCall.enabled) {
240+
this.on('call-incoming', (call) => void this.autoRejectCall.handle(call))
241+
}
225242
this.autoDeleteOptions =
226243
options.autoDelete === false
227244
? undefined
@@ -762,6 +779,26 @@ export class Client extends TypedEventEmitter<ClientEventMap> {
762779
return reactToMessage(this.requireBuilderSocket(), key, emoji)
763780
}
764781

782+
/**
783+
* Reject an incoming call (🔗 unofficial only — the Cloud API has no calls). Pass the
784+
* `call-incoming` payload directly, or the raw `callId` + caller jid.
785+
*/
786+
async rejectCall(call: Extract<CallPayload, { kind: 'incoming' }>): Promise<void>
787+
async rejectCall(callId: string, from: string): Promise<void>
788+
async rejectCall(
789+
callOrId: Extract<CallPayload, { kind: 'incoming' }> | string,
790+
from?: string,
791+
): Promise<void> {
792+
this.assertWebProvider('rejectCall')
793+
if (typeof callOrId === 'string') {
794+
if (typeof from !== 'string' || from.length === 0) {
795+
throw new ZaileysAutomationError('NOT_CONNECTED', 'rejectCall(callId, from) requires the caller jid')
796+
}
797+
return this.autoRejectCall.reject(callOrId, from)
798+
}
799+
return this.autoRejectCall.reject(callOrId.callId, callOrId.from)
800+
}
801+
765802
/** Cloud provider only: management surface (templates, profile, flows, blocklist, qr, analytics, phone). */
766803
get cloud(): CloudModule {
767804
if (this._provider !== 'cloud') {

src/client/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { UserFacingSocketConfig, WASocket } from 'baileys'
22
import type { AuthStoreBundle } from '../auth/types.js'
33
import type { AutoDeleteOptions } from '../automation/auto-delete.js'
4+
import type { AutoRejectCallOptions } from '../automation/auto-reject-call.js'
45
import type { OperationGuardOptions } from '../automation/operation-guard.js'
56
import type { PresenceThrottleOptions } from '../automation/presence.js'
67
import type { AuthGuardOptions } from '../connection/auth-guard.js'
@@ -77,6 +78,8 @@ export interface ClientOptions {
7778
presence?: PresenceThrottleOptions
7879
/** Max scheduled messages dispatched per second, smoothing backlog bursts. Default `1`; `0` disables. */
7980
scheduleRateLimitPerSec?: number
81+
/** Auto-reject incoming calls (🔗 unofficial only). `true` = reject every call. Default off. */
82+
autoRejectCall?: boolean | AutoRejectCallOptions
8083
/** Periodically prune old messages from the store. Enabled by default with a 1-month `maxAgeMs`; pass `false` to disable or override any field. */
8184
autoDelete?: AutoDeleteOptions | false
8285
/** Load and manage plugins from a directory. */
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import { describe, expect, it, vi, beforeEach } from 'vitest'
2+
import { createMockSocket } from '../_helpers/mock-socket.js'
3+
4+
const { makeWASocketMock, initAuthCredsMock } = vi.hoisted(() => ({
5+
makeWASocketMock: vi.fn(),
6+
initAuthCredsMock: vi.fn(() => ({ fake: 'creds' })),
7+
}))
8+
9+
vi.mock('baileys', () => ({
10+
default: makeWASocketMock,
11+
makeWASocket: makeWASocketMock,
12+
initAuthCreds: initAuthCredsMock,
13+
DisconnectReason: {
14+
loggedOut: 401, forbidden: 403, connectionLost: 408, multideviceMismatch: 411,
15+
connectionClosed: 428, connectionReplaced: 440, badSession: 500,
16+
unavailableService: 503, restartRequired: 515, timedOut: 408,
17+
},
18+
makeCacheableSignalKeyStore: vi.fn((k: unknown) => k),
19+
BufferJSON: { replacer: (_k: string, v: unknown) => v, reviver: (_k: string, v: unknown) => v },
20+
}))
21+
22+
vi.mock('../../src/connection/qr-terminal.js', async () => {
23+
const actual = await vi.importActual<typeof import('../../src/connection/qr-terminal.js')>(
24+
'../../src/connection/qr-terminal.js',
25+
)
26+
return { ...actual, printQrToTerminal: vi.fn(async () => undefined) }
27+
})
28+
29+
import { Client } from '../../src/client/client.js'
30+
import { MemoryAuthStore } from '../../src/auth/adapters/memory.js'
31+
import type { ClientOptions } from '../../src/client/types.js'
32+
33+
beforeEach(() => {
34+
makeWASocketMock.mockReset()
35+
initAuthCredsMock.mockClear()
36+
})
37+
38+
const CALLER = '628111@s.whatsapp.net'
39+
40+
function mkSocket() {
41+
const sock = createMockSocket({ user: { id: 'me@s.whatsapp.net' } })
42+
const rejectCall = vi.fn(async () => undefined)
43+
Object.assign(sock, { rejectCall })
44+
return Object.assign(sock, { rejectCall })
45+
}
46+
47+
async function boot(opts: Partial<ClientOptions> = {}) {
48+
const sock = mkSocket()
49+
makeWASocketMock.mockReturnValue(sock)
50+
const c = new Client({
51+
auth: new MemoryAuthStore(), qrTerminal: false, autoConnect: false, statusLog: false, ...opts,
52+
})
53+
const p = c.connect()
54+
sock.triggerConnectionUpdate({ connection: 'open' })
55+
await p
56+
return { c, sock }
57+
}
58+
59+
const ringCall = (sock: ReturnType<typeof mkSocket>, from = CALLER) =>
60+
sock.ev.emit('call', [
61+
{ id: 'CALL1', from, status: 'offer', date: new Date(), isGroup: false, isVideo: false },
62+
])
63+
64+
const settle = () => new Promise((r) => setTimeout(r, 20))
65+
66+
describe('autoRejectCall config', () => {
67+
it('rejects an incoming call when autoRejectCall: true', async () => {
68+
const { sock } = await boot({ autoRejectCall: true })
69+
ringCall(sock)
70+
await settle()
71+
expect(sock.rejectCall).toHaveBeenCalledWith('CALL1', CALLER)
72+
})
73+
74+
it('does NOT reject by default (opt-in)', async () => {
75+
const { sock } = await boot()
76+
ringCall(sock)
77+
await settle()
78+
expect(sock.rejectCall).not.toHaveBeenCalled()
79+
})
80+
81+
it('autoRejectCall: false does not reject', async () => {
82+
const { sock } = await boot({ autoRejectCall: false })
83+
ringCall(sock)
84+
await settle()
85+
expect(sock.rejectCall).not.toHaveBeenCalled()
86+
})
87+
88+
it('respects the allow list end-to-end', async () => {
89+
const { sock } = await boot({ autoRejectCall: { enabled: true, allow: [CALLER] } })
90+
ringCall(sock)
91+
await settle()
92+
expect(sock.rejectCall).not.toHaveBeenCalled()
93+
94+
ringCall(sock, '628999@s.whatsapp.net')
95+
await settle()
96+
expect(sock.rejectCall).toHaveBeenCalledOnce()
97+
})
98+
99+
it('runs onReject with the call payload', async () => {
100+
const onReject = vi.fn()
101+
const { sock } = await boot({ autoRejectCall: { enabled: true, onReject } })
102+
ringCall(sock)
103+
await settle()
104+
expect(onReject).toHaveBeenCalledOnce()
105+
expect(onReject.mock.calls[0]?.[0]).toMatchObject({ callId: 'CALL1', from: CALLER })
106+
})
107+
108+
it('rejects exactly once per call (no double-attach)', async () => {
109+
const { sock } = await boot({ autoRejectCall: true })
110+
ringCall(sock)
111+
await settle()
112+
expect(sock.rejectCall).toHaveBeenCalledTimes(1)
113+
})
114+
})
115+
116+
describe('client.rejectCall()', () => {
117+
it('accepts the call payload from the event', async () => {
118+
const { c, sock } = await boot()
119+
const seen = new Promise<void>((resolve) => {
120+
c.on('call-incoming', async (call) => {
121+
await c.rejectCall(call)
122+
resolve()
123+
})
124+
})
125+
ringCall(sock)
126+
await seen
127+
expect(sock.rejectCall).toHaveBeenCalledWith('CALL1', CALLER)
128+
})
129+
130+
it('accepts (callId, from)', async () => {
131+
const { c, sock } = await boot()
132+
await c.rejectCall('CALL9', '628222@s.whatsapp.net')
133+
expect(sock.rejectCall).toHaveBeenCalledWith('CALL9', '628222@s.whatsapp.net')
134+
})
135+
136+
it('throws UNSUPPORTED_ON_CLOUD on the cloud provider', async () => {
137+
const c = new Client({
138+
provider: 'cloud',
139+
cloud: { accessToken: 'tok', phoneNumberId: '555' },
140+
autoConnect: false, statusLog: false,
141+
})
142+
await expect(c.rejectCall('C', 'x@s.whatsapp.net')).rejects.toMatchObject({
143+
code: 'UNSUPPORTED_ON_CLOUD',
144+
})
145+
})
146+
})

0 commit comments

Comments
 (0)