@lizzythelizard/whatsapp-mcp 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/sync.test.ts DELETED
@@ -1,304 +0,0 @@
1
- import { describe, it, expect, vi, beforeEach } from 'vitest'
2
-
3
- const mockMakeWASocket = vi.hoisted(() => vi.fn())
4
-
5
- vi.mock('@whiskeysockets/baileys', async (importOriginal) => {
6
- const actual: object = await importOriginal()
7
- return { ...actual, default: mockMakeWASocket, makeWASocket: mockMakeWASocket }
8
- })
9
-
10
- import { createHandler } from './sync.js'
11
- import { createStore } from './store.js'
12
-
13
- function createStoreMockEmitter() {
14
- return { process: vi.fn() }
15
- }
16
-
17
- function newMockSocket() {
18
- return {
19
- ev: {
20
- on: vi.fn(),
21
- process: vi.fn(),
22
- },
23
- end: vi.fn().mockResolvedValue(undefined),
24
- sendMessage: vi.fn().mockResolvedValue({ key: { id: 'mock', remoteJid: 'test' }, message: { conversation: 'ok' } }),
25
- chatModify: vi.fn().mockResolvedValue(undefined),
26
- }
27
- }
28
-
29
- function getConnectionUpdateHandler(socket: ReturnType<typeof newMockSocket>): ((update: Record<string, unknown>) => void) | undefined {
30
- const call = (socket.ev.on.mock.calls as [string, (update: Record<string, unknown>) => void][])
31
- .find(([name]) => name === 'connection.update')
32
- return call?.[1]
33
- }
34
-
35
- function emitConnectionUpdate(update: Record<string, unknown>) {
36
- const results = mockMakeWASocket.mock.results as { value: ReturnType<typeof newMockSocket>, type: string }[]
37
- if (results.length === 0) return
38
- const socket = results[results.length - 1].value
39
- const handler = getConnectionUpdateHandler(socket)
40
- if (handler) handler(update)
41
- }
42
-
43
- function getLatestSocket(): ReturnType<typeof newMockSocket> {
44
- const results = mockMakeWASocket.mock.results as { value: ReturnType<typeof newMockSocket>, type: string }[]
45
- return results[results.length - 1].value
46
- }
47
-
48
- beforeEach(() => {
49
- mockMakeWASocket.mockReset()
50
- mockMakeWASocket.mockReturnValue(newMockSocket())
51
- })
52
-
53
- describe('createHandler', () => {
54
- it('initial state is connecting', () => {
55
- const handler = createHandler(createStore())
56
- expect(handler.getStatus()).toEqual({ type: 'connecting' })
57
- })
58
-
59
- it('transitions to needAuth on QR', () => {
60
- const handler = createHandler(createStore())
61
- emitConnectionUpdate({ qr: 'base64qr===' })
62
- expect(handler.getStatus()).toEqual({ type: 'needAuth', qr: 'base64qr===' })
63
- })
64
-
65
- it('transitions to ready on connection open', () => {
66
- const handler = createHandler(createStore())
67
- emitConnectionUpdate({ connection: 'open' })
68
- expect(handler.getStatus()).toEqual({ type: 'ready' })
69
- })
70
-
71
- it('transitions to closed on connection close without error', () => {
72
- const handler = createHandler(createStore())
73
- emitConnectionUpdate({ connection: 'close' })
74
- expect(handler.getStatus()).toEqual({ type: 'closed' })
75
- })
76
-
77
- it('transitions to closed with error on connection close with error', () => {
78
- const handler = createHandler(createStore())
79
- const error = new Error('generic failure')
80
- emitConnectionUpdate({ connection: 'close', lastDisconnect: { error } })
81
- expect(handler.getStatus()).toEqual({ type: 'closed', error })
82
- })
83
-
84
- it('ignores intermediate connection updates', () => {
85
- const handler = createHandler(createStore())
86
- emitConnectionUpdate({ connection: 'connecting' })
87
- expect(handler.getStatus()).toEqual({ type: 'connecting' })
88
- })
89
- })
90
-
91
- describe('error handling', () => {
92
- it('handles 401 error by resetting store and restarting', () => {
93
- const store = createStore()
94
- const handler = createHandler(store)
95
- const error = Object.assign(new Error('auth failed'), { output: { statusCode: 401 } })
96
- emitConnectionUpdate({ connection: 'close', lastDisconnect: { error } })
97
- expect(handler.getStatus()).toEqual({ type: 'connecting' })
98
- expect(mockMakeWASocket).toHaveBeenCalledTimes(2)
99
- })
100
-
101
- it('handles 515 error by resetting store and restarting with re-login', () => {
102
- const store = createStore()
103
- const handler = createHandler(store)
104
- const error = Object.assign(new Error('reconnect needed'), { output: { statusCode: 515 } })
105
- emitConnectionUpdate({ connection: 'close', lastDisconnect: { error } })
106
- expect(handler.getStatus()).toEqual({ type: 'connecting' })
107
- expect(mockMakeWASocket).toHaveBeenCalledTimes(2)
108
- })
109
-
110
- it('non-401/515 error stays closed with error', () => {
111
- const handler = createHandler(createStore())
112
- const error = Object.assign(new Error('other error'), { output: { statusCode: 500 } })
113
- emitConnectionUpdate({ connection: 'close', lastDisconnect: { error } })
114
- expect(handler.getStatus()).toEqual({ type: 'closed', error })
115
- })
116
-
117
- it('plain Error without output does not trigger reconnection', () => {
118
- const handler = createHandler(createStore())
119
- const error = new Error('no output property')
120
- emitConnectionUpdate({ connection: 'close', lastDisconnect: { error } })
121
- expect(handler.getStatus()).toEqual({ type: 'closed', error })
122
- })
123
-
124
- it('non-Error value falls through to closed with the value as error', () => {
125
- const handler = createHandler(createStore())
126
- emitConnectionUpdate({ connection: 'close', lastDisconnect: { error: 'string error' } })
127
- expect(handler.getStatus().type).toBe('closed')
128
- expect(handler.getStatus()).toHaveProperty('error', 'string error')
129
- })
130
-
131
- it('401 error resets store data', () => {
132
- const store = createStore()
133
- const ev = createStoreMockEmitter()
134
- store.bind(ev)
135
- createHandler(store)
136
- ev.process.mockClear()
137
- const error = Object.assign(new Error('auth'), { output: { statusCode: 401 } })
138
- emitConnectionUpdate({ connection: 'close', lastDisconnect: { error } })
139
- expect(store.getChats()).toHaveLength(0)
140
- })
141
- })
142
-
143
- describe('getStatus', () => {
144
- it('returns the current state', () => {
145
- const store = createStore()
146
- const handler = createHandler(store)
147
- expect(handler.getStatus().type).toBe('connecting')
148
- emitConnectionUpdate({ connection: 'open' })
149
- expect(handler.getStatus().type).toBe('ready')
150
- })
151
- })
152
-
153
- describe('sendMessage', () => {
154
- it('throws when state is connecting', async () => {
155
- const handler = createHandler(createStore())
156
- await expect(handler.sendMessage('test@s.whatsapp.net', 'hello')).rejects.toThrow('Server still connecting')
157
- })
158
-
159
- it('throws when state is closed', async () => {
160
- const handler = createHandler(createStore())
161
- emitConnectionUpdate({ connection: 'close' })
162
- await expect(handler.sendMessage('test@s.whatsapp.net', 'hello')).rejects.toThrow('Connection closed')
163
- })
164
-
165
- it('throws when state is needAuth', async () => {
166
- const handler = createHandler(createStore())
167
- emitConnectionUpdate({ qr: 'qr' })
168
- await expect(handler.sendMessage('test@s.whatsapp.net', 'hello')).rejects.toThrow('Authentication needed')
169
- })
170
-
171
- it('calls sock.sendMessage when ready', async () => {
172
- const handler = createHandler(createStore())
173
- emitConnectionUpdate({ connection: 'open' })
174
- const socket = getLatestSocket()
175
- const result = await handler.sendMessage('test@s.whatsapp.net', 'Hello!')
176
- expect(socket.sendMessage).toHaveBeenCalledWith('test@s.whatsapp.net', { text: 'Hello!' })
177
- expect(result).toBeDefined()
178
- })
179
-
180
- it('throws when sock.sendMessage returns null', async () => {
181
- const handler = createHandler(createStore())
182
- getLatestSocket().sendMessage.mockResolvedValue(null)
183
- emitConnectionUpdate({ connection: 'open' })
184
- await expect(handler.sendMessage('test@s.whatsapp.net', 'hi')).rejects.toThrow('Failed to send message')
185
- })
186
- })
187
-
188
- describe('setArchived', () => {
189
- it('throws when state is connecting', async () => {
190
- const handler = createHandler(createStore())
191
- await expect(handler.setArchived('test@s.whatsapp.net', true)).rejects.toThrow('Server still connecting')
192
- })
193
-
194
- it('throws when chat is not found', async () => {
195
- const handler = createHandler(createStore())
196
- emitConnectionUpdate({ connection: 'open' })
197
- await expect(handler.setArchived('unknown@s.whatsapp.net', true)).rejects.toThrow('No chat found')
198
- })
199
-
200
- it('calls chatModify with empty lastMessages when chat has no last message', async () => {
201
- const store = createStore()
202
- const ev = createStoreMockEmitter()
203
- store.bind(ev)
204
- const handler = createHandler(store)
205
- emitConnectionUpdate({ connection: 'open' })
206
- store.getChat = vi.fn().mockReturnValue({ id: 'test@s.whatsapp.net' })
207
- const socket = getLatestSocket()
208
- await handler.setArchived('test@s.whatsapp.net', true)
209
- expect(socket.chatModify).toHaveBeenCalledWith(
210
- { archive: true, lastMessages: [] },
211
- 'test@s.whatsapp.net',
212
- )
213
- })
214
-
215
- it('throws when last message has no key', async () => {
216
- const store = createStore()
217
- const ev = createStoreMockEmitter()
218
- store.bind(ev)
219
- const handler = createHandler(store)
220
- emitConnectionUpdate({ connection: 'open' })
221
- store.getChat = vi.fn().mockReturnValue({ id: 'test@s.whatsapp.net', messages: [{ message: {} }] })
222
- await expect(handler.setArchived('test@s.whatsapp.net', true)).rejects.toThrow('has no key')
223
- })
224
-
225
- it('calls chatModify with archive and last message when valid', async () => {
226
- const store = createStore()
227
- const ev = createStoreMockEmitter()
228
- store.bind(ev)
229
- const handler = createHandler(store)
230
- emitConnectionUpdate({ connection: 'open' })
231
- const lastMsg = { key: { id: 'last', remoteJid: 'test@s.whatsapp.net' }, message: { conversation: 'bye' } }
232
- store.getChat = vi.fn().mockReturnValue({ id: 'test@s.whatsapp.net', messages: [{ message: lastMsg }] })
233
- const socket = getLatestSocket()
234
- await handler.setArchived('test@s.whatsapp.net', true)
235
- expect(socket.chatModify).toHaveBeenCalledWith(
236
- { archive: true, lastMessages: [lastMsg] },
237
- 'test@s.whatsapp.net',
238
- )
239
- })
240
- })
241
-
242
- describe('setRead', () => {
243
- it('throws when state is connecting', async () => {
244
- const handler = createHandler(createStore())
245
- await expect(handler.setRead('test@s.whatsapp.net', true)).rejects.toThrow('Server still connecting')
246
- })
247
-
248
- it('throws when chat is not found', async () => {
249
- const handler = createHandler(createStore())
250
- emitConnectionUpdate({ connection: 'open' })
251
- await expect(handler.setRead('unknown@s.whatsapp.net', true)).rejects.toThrow('No chat found')
252
- })
253
-
254
- it('calls chatModify with empty lastMessages when chat has no last message', async () => {
255
- const store = createStore()
256
- const ev = createStoreMockEmitter()
257
- store.bind(ev)
258
- const handler = createHandler(store)
259
- emitConnectionUpdate({ connection: 'open' })
260
- store.getChat = vi.fn().mockReturnValue({ id: 'test@s.whatsapp.net' })
261
- const socket = getLatestSocket()
262
- await handler.setRead('test@s.whatsapp.net', true)
263
- expect(socket.chatModify).toHaveBeenCalledWith(
264
- { markRead: true, lastMessages: [] },
265
- 'test@s.whatsapp.net',
266
- )
267
- })
268
-
269
- it('throws when last message has no key', async () => {
270
- const store = createStore()
271
- const ev = createStoreMockEmitter()
272
- store.bind(ev)
273
- const handler = createHandler(store)
274
- emitConnectionUpdate({ connection: 'open' })
275
- store.getChat = vi.fn().mockReturnValue({ id: 'test@s.whatsapp.net', messages: [{ message: {} }] })
276
- await expect(handler.setRead('test@s.whatsapp.net', true)).rejects.toThrow('has no key')
277
- })
278
-
279
- it('calls chatModify with markRead and last message when valid', async () => {
280
- const store = createStore()
281
- const ev = createStoreMockEmitter()
282
- store.bind(ev)
283
- const handler = createHandler(store)
284
- emitConnectionUpdate({ connection: 'open' })
285
- const lastMsg = { key: { id: 'last', remoteJid: 'test@s.whatsapp.net' }, message: { conversation: 'hi' } }
286
- store.getChat = vi.fn().mockReturnValue({ id: 'test@s.whatsapp.net', messages: [{ message: lastMsg }] })
287
- const socket = getLatestSocket()
288
- await handler.setRead('test@s.whatsapp.net', true)
289
- expect(socket.chatModify).toHaveBeenCalledWith(
290
- { markRead: true, lastMessages: [lastMsg] },
291
- 'test@s.whatsapp.net',
292
- )
293
- })
294
- })
295
-
296
- describe('close', () => {
297
- it('sets state to closed and calls sock.end', () => {
298
- const handler = createHandler(createStore())
299
- const socket = getLatestSocket()
300
- handler.close()
301
- expect(socket.end).toHaveBeenCalled()
302
- expect(handler.getStatus()).toEqual({ type: 'closed' })
303
- })
304
- })
package/src/sync.ts DELETED
@@ -1,170 +0,0 @@
1
- import makeWASocket, { WABrowserDescription, ConnectionState, WAMessage, proto } from '@whiskeysockets/baileys'
2
- import { createStore } from './store.js'
3
-
4
- export type SyncStatus = { type: 'connecting' } | { type: 'needAuth', qr: string } | { type: 'ready' } | { type: 'closed', error?: Error }
5
-
6
- export interface WhatsAppHandler {
7
- close: () => void
8
- getStatus: () => SyncStatus
9
- sendMessage: (jid: string, text: string) => Promise<WAMessage>
10
- setRead: (jid: string, read: boolean) => Promise<void>
11
- setArchived: (jid: string, archived: boolean) => Promise<void>
12
- }
13
-
14
- export function createHandler(store: ReturnType<typeof createStore>): WhatsAppHandler {
15
- let state: SyncStatus = { type: 'connecting' }
16
- let sock: ReturnType<typeof makeWASocket> | undefined
17
- const browser = ['Gutschi.site', 'Desktop', '1.0.0'] as WABrowserDescription
18
-
19
- function onError(error: unknown): void {
20
- const arg = error instanceof Error ? error : new Error(String(error))
21
- console.error(`Closing WhatsApp sync due to error ${arg}`)
22
- close(arg)
23
- }
24
-
25
- function close(error?: Error): void {
26
- if (sock) {
27
- const sockCpy = sock
28
- sock = undefined
29
- sockCpy.end(error).catch(onClosed)
30
- }
31
- state = { type: 'closed', error }
32
- }
33
-
34
- function onClosed(error?: Error): void {
35
- if (!error) {
36
- console.log(`WhatsApp sync connection closed without error`)
37
- state = { type: 'closed' }
38
- return
39
- }
40
- if (isInvalidAuthError(error)) {
41
- console.warn(`WhatsApp sync requires re-authentication due to invalid auth state`)
42
- store.reset()
43
- start()
44
- return
45
- }
46
- if (isRequiredReconnectError(error)) {
47
- console.log(`WhatsApp sync connection closed due to required reconnect`)
48
- store.reset()
49
- startAgainAfterLogin()
50
- return
51
- }
52
- state = { type: 'closed', error }
53
- }
54
-
55
- function start() {
56
- sock = makeWASocket({ auth: store.getAuth(), browser, logger: baileysLogger, markOnlineOnConnect: false, syncFullHistory: true, emitOwnEvents: true })
57
- sock.ev.on('connection.update', (update) => {
58
- connectionUpdate(update, onClosed,
59
- qr => state = { type: 'needAuth', qr },
60
- () => state = { type: 'ready' })
61
- },
62
- )
63
- store.bind(sock.ev)
64
- }
65
-
66
- function startAgainAfterLogin() {
67
- sock = makeWASocket({ auth: store.getAuth(), browser, logger: baileysLogger, markOnlineOnConnect: false, syncFullHistory: true, emitOwnEvents: true })
68
- sock.ev.on('connection.update', (update) => { connectionUpdateAfterLogin(update, onClosed, onError) })
69
- // wait untill no new messages are received for 2 seconds, then set state to ready
70
- let timeout = setTimeout(() => state = { type: 'ready' }, 2000)
71
- sock.ev.process(() => {
72
- clearTimeout(timeout)
73
- timeout = setTimeout(() => state = { type: 'ready' }, 2000)
74
- })
75
- store.bind(sock.ev)
76
- }
77
-
78
- async function sendMessage(jid: string, message: string): Promise<WAMessage> {
79
- if (state.type === 'connecting') throw new Error('Server still connecting, please wait')
80
- if (state.type === 'closed') throw new Error('Connection closed, please restart server')
81
- if (state.type === 'needAuth') throw new Error('Authentication needed, please authenticate yourself first')
82
- if (sock === undefined) throw new Error(`No Socket defined but state is ${state.type}. This is invalid, please restart server`)
83
- const result = await sock.sendMessage(jid, { text: message })
84
- if (!result) throw new Error(`Failed to send message to ${jid}`)
85
- return result
86
- }
87
-
88
- async function setArchived(jid: string, archived: boolean): Promise<void> {
89
- if (state.type === 'connecting') throw new Error('Server still connecting, please wait')
90
- if (state.type === 'closed') throw new Error('Connection closed, please restart server')
91
- if (state.type === 'needAuth') throw new Error('Authentication needed, please authenticate yourself first')
92
- if (sock === undefined) throw new Error(`No Socket defined but state is ${state.type}. This is invalid, please restart server`)
93
- const chat = store.getChat(jid)
94
- if (!chat) throw new Error(`No chat found for ${jid}`)
95
- const lastMessage = chat.messages?.[0].message
96
- if (!lastMessage) {
97
- await sock.chatModify({ archive: archived, lastMessages: [] }, jid)
98
- return
99
- }
100
- if (!lastMessage.key) throw new Error(`Last message for ${jid} has no key, cannot archive chat`)
101
- await sock.chatModify({ archive: archived, lastMessages: [lastMessage as proto.IWebMessageInfo & { key: typeof lastMessage.key }] }, jid)
102
- }
103
-
104
- async function setRead(jid: string, read: boolean): Promise<void> {
105
- if (state.type === 'connecting') throw new Error('Server still connecting, please wait')
106
- if (state.type === 'closed') throw new Error('Connection closed, please restart server')
107
- if (state.type === 'needAuth') throw new Error('Authentication needed, please authenticate yourself first')
108
- if (sock === undefined) throw new Error(`No Socket defined but state is ${state.type}. This is invalid, please restart server`)
109
- const chat = store.getChat(jid)
110
- if (!chat) throw new Error(`No chat found for ${jid}`)
111
- const lastMessage = chat.messages?.[0].message
112
- if (!lastMessage) {
113
- await sock.chatModify({ markRead: read, lastMessages: [] }, jid)
114
- return
115
- }
116
- if (!lastMessage.key) throw new Error(`Last message for ${jid} has no key, cannot mark chat as unread`)
117
- await sock.chatModify({ markRead: read, lastMessages: [lastMessage as proto.IWebMessageInfo & { key: typeof lastMessage.key }] }, jid)
118
- }
119
- start()
120
-
121
- return {
122
- getStatus: () => state,
123
- sendMessage: sendMessage,
124
- setArchived: setArchived,
125
- setRead: setRead,
126
- close: close,
127
- }
128
- }
129
-
130
- const baileysLogger = {
131
- level: 'error',
132
- child: () => baileysLogger,
133
- trace: () => { /* empty */ },
134
- debug: () => { /* empty */ },
135
- info: () => { /* empty */ },
136
- warn: () => { /* empty */ },
137
- error: () => { /* empty */ },
138
- }
139
-
140
- function connectionUpdate(update: Partial<ConnectionState>, onClose: (error?: Error) => void, onQr?: (qr: string) => void, onReady?: () => void): void {
141
- if (update.qr) onQr?.(update.qr)
142
- else if (update.connection === 'open') onReady?.()
143
- else if (update.connection !== 'close') { /* do nothing, wait for next update */ }
144
- else onClose(update.lastDisconnect?.error)
145
- }
146
-
147
- function isInvalidAuthError(error: unknown): boolean {
148
- if (!(error instanceof Error)) return false
149
- if (!('output' in error)) return false
150
- if (typeof error.output !== 'object' || error.output === null) return false
151
- if (!('statusCode' in error.output)) return false
152
- if (error.output.statusCode !== 401) return false
153
- return true
154
- }
155
-
156
- function isRequiredReconnectError(error: unknown): boolean {
157
- if (!(error instanceof Error)) return false
158
- if (!('output' in error)) return false
159
- if (typeof error.output !== 'object' || error.output === null) return false
160
- if (!('statusCode' in error.output)) return false
161
- if (error.output.statusCode !== 515) return false
162
- return true
163
- }
164
-
165
- function connectionUpdateAfterLogin(update: Partial<ConnectionState>, onClose: (error?: Error) => void, onErr: (error: Error) => void): void {
166
- if (update.qr) onErr(new Error('Received QR code update during WhatsApp sync after login'))
167
- else if (update.connection === 'open') { /* do nothing, wait for next update */ }
168
- else if (update.connection !== 'close') { /* do nothing, wait for next update */ }
169
- else onClose(update.lastDisconnect?.error)
170
- }