返回 JoyAI-Echo
whatsapp.ts
1 /**
2 * WhatsApp client wrapper using Baileys.
3 * Based on OpenClaw's working implementation.
4 */
5
6 /* eslint-disable @typescript-eslint/no-explicit-any */
7 import makeWASocket, {
8 DisconnectReason,
9 useMultiFileAuthState,
10 fetchLatestBaileysVersion,
11 makeCacheableSignalKeyStore,
12 downloadMediaMessage,
13 extractMessageContent as baileysExtractMessageContent,
14 } from '@whiskeysockets/baileys';
15
16 import { Boom } from '@hapi/boom';
17 import qrcode from 'qrcode-terminal';
18 import pino from 'pino';
19 import { readFile, writeFile, mkdir } from 'fs/promises';
20 import { join, basename } from 'path';
21 import { randomBytes } from 'crypto';
22
23 const VERSION = '0.1.0';
24
25 export interface InboundMessage {
26 id: string;
27 sender: string;
28 pn: string;
29 content: string;
30 timestamp: number;
31 isGroup: boolean;
32 wasMentioned?: boolean;
33 media?: string[];
34 }
35
36 export interface WhatsAppClientOptions {
37 authDir: string;
38 onMessage: (msg: InboundMessage) => void;
39 onQR: (qr: string) => void;
40 onStatus: (status: string) => void;
41 }
42
43 export class WhatsAppClient {
44 private sock: any = null;
45 private options: WhatsAppClientOptions;
46 private reconnecting = false;
47
48 constructor(options: WhatsAppClientOptions) {
49 this.options = options;
50 }
51
52 private normalizeJid(jid: string | undefined | null): string {
53 return (jid || '').split(':')[0];
54 }
55
56 private wasMentioned(msg: any): boolean {
57 if (!msg?.key?.remoteJid?.endsWith('@g.us')) return false;
58
59 const candidates = [
60 msg?.message?.extendedTextMessage?.contextInfo?.mentionedJid,
61 msg?.message?.imageMessage?.contextInfo?.mentionedJid,
62 msg?.message?.videoMessage?.contextInfo?.mentionedJid,
63 msg?.message?.documentMessage?.contextInfo?.mentionedJid,
64 msg?.message?.audioMessage?.contextInfo?.mentionedJid,
65 ];
66 const mentioned = candidates.flatMap((items) => (Array.isArray(items) ? items : []));
67 if (mentioned.length === 0) return false;
68
69 const selfIds = new Set(
70 [this.sock?.user?.id, this.sock?.user?.lid, this.sock?.user?.jid]
71 .map((jid) => this.normalizeJid(jid))
72 .filter(Boolean),
73 );
74 return mentioned.some((jid: string) => selfIds.has(this.normalizeJid(jid)));
75 }
76
77 async connect(): Promise<void> {
78 const logger = pino({ level: 'silent' });
79 const { state, saveCreds } = await useMultiFileAuthState(this.options.authDir);
80 const { version } = await fetchLatestBaileysVersion();
81
82 console.log(`Using Baileys version: ${version.join('.')}`);
83
84 // Create socket following OpenClaw's pattern
85 this.sock = makeWASocket({
86 auth: {
87 creds: state.creds,
88 keys: makeCacheableSignalKeyStore(state.keys, logger),
89 },
90 version,
91 logger,
92 printQRInTerminal: false,
93 browser: ['nanobot', 'cli', VERSION],
94 syncFullHistory: false,
95 markOnlineOnConnect: false,
96 });
97
98 // Handle WebSocket errors
99 if (this.sock.ws && typeof this.sock.ws.on === 'function') {
100 this.sock.ws.on('error', (err: Error) => {
101 console.error('WebSocket error:', err.message);
102 });
103 }
104
105 // Handle connection updates
106 this.sock.ev.on('connection.update', async (update: any) => {
107 const { connection, lastDisconnect, qr } = update;
108
109 if (qr) {
110 // Display QR code in terminal
111 console.log('\n📱 Scan this QR code with WhatsApp (Linked Devices):\n');
112 qrcode.generate(qr, { small: true });
113 this.options.onQR(qr);
114 }
115
116 if (connection === 'close') {
117 const statusCode = (lastDisconnect?.error as Boom)?.output?.statusCode;
118 const shouldReconnect = statusCode !== DisconnectReason.loggedOut;
119
120 console.log(`Connection closed. Status: ${statusCode}, Will reconnect: ${shouldReconnect}`);
121 this.options.onStatus('disconnected');
122
123 if (shouldReconnect && !this.reconnecting) {
124 this.reconnecting = true;
125 console.log('Reconnecting in 5 seconds...');
126 setTimeout(() => {
127 this.reconnecting = false;
128 this.connect();
129 }, 5000);
130 }
131 } else if (connection === 'open') {
132 console.log('✅ Connected to WhatsApp');
133 this.options.onStatus('connected');
134 }
135 });
136
137 // Save credentials on update
138 this.sock.ev.on('creds.update', saveCreds);
139
140 // Handle incoming messages
141 this.sock.ev.on('messages.upsert', async ({ messages, type }: { messages: any[]; type: string }) => {
142 if (type !== 'notify') return;
143
144 for (const msg of messages) {
145 if (msg.key.fromMe) continue;
146 if (msg.key.remoteJid === 'status@broadcast') continue;
147
148 const unwrapped = baileysExtractMessageContent(msg.message);
149 if (!unwrapped) continue;
150
151 const content = this.getTextContent(unwrapped);
152 let fallbackContent: string | null = null;
153 const mediaPaths: string[] = [];
154
155 if (unwrapped.imageMessage) {
156 fallbackContent = '[Image]';
157 const path = await this.downloadMedia(msg, unwrapped.imageMessage.mimetype ?? undefined);
158 if (path) mediaPaths.push(path);
159 } else if (unwrapped.documentMessage) {
160 fallbackContent = '[Document]';
161 const path = await this.downloadMedia(msg, unwrapped.documentMessage.mimetype ?? undefined,
162 unwrapped.documentMessage.fileName ?? undefined);
163 if (path) mediaPaths.push(path);
164 } else if (unwrapped.videoMessage) {
165 fallbackContent = '[Video]';
166 const path = await this.downloadMedia(msg, unwrapped.videoMessage.mimetype ?? undefined);
167 if (path) mediaPaths.push(path);
168 }
169
170 const finalContent = content || (mediaPaths.length === 0 ? fallbackContent : '') || '';
171 if (!finalContent && mediaPaths.length === 0) continue;
172
173 const isGroup = msg.key.remoteJid?.endsWith('@g.us') || false;
174 const wasMentioned = this.wasMentioned(msg);
175
176 this.options.onMessage({
177 id: msg.key.id || '',
178 sender: msg.key.remoteJid || '',
179 pn: msg.key.remoteJidAlt || '',
180 content: finalContent,
181 timestamp: msg.messageTimestamp as number,
182 isGroup,
183 ...(isGroup ? { wasMentioned } : {}),
184 ...(mediaPaths.length > 0 ? { media: mediaPaths } : {}),
185 });
186 }
187 });
188 }
189
190 private async downloadMedia(msg: any, mimetype?: string, fileName?: string): Promise<string | null> {
191 try {
192 const mediaDir = join(this.options.authDir, '..', 'media');
193 await mkdir(mediaDir, { recursive: true });
194
195 const buffer = await downloadMediaMessage(msg, 'buffer', {}) as Buffer;
196
197 let outFilename: string;
198 if (fileName) {
199 // Documents have a filename — use it with a unique prefix to avoid collisions
200 const prefix = `wa_${Date.now()}_${randomBytes(4).toString('hex')}_`;
201 outFilename = prefix + fileName;
202 } else {
203 const mime = mimetype || 'application/octet-stream';
204 // Derive extension from mimetype subtype (e.g. "image/png" → ".png", "application/pdf" → ".pdf")
205 const ext = '.' + (mime.split('/').pop()?.split(';')[0] || 'bin');
206 outFilename = `wa_${Date.now()}_${randomBytes(4).toString('hex')}${ext}`;
207 }
208
209 const filepath = join(mediaDir, outFilename);
210 await writeFile(filepath, buffer);
211
212 return filepath;
213 } catch (err) {
214 console.error('Failed to download media:', err);
215 return null;
216 }
217 }
218
219 private getTextContent(message: any): string | null {
220 // Text message
221 if (message.conversation) {
222 return message.conversation;
223 }
224
225 // Extended text (reply, link preview)
226 if (message.extendedTextMessage?.text) {
227 return message.extendedTextMessage.text;
228 }
229
230 // Image with optional caption
231 if (message.imageMessage) {
232 return message.imageMessage.caption || '';
233 }
234
235 // Video with optional caption
236 if (message.videoMessage) {
237 return message.videoMessage.caption || '';
238 }
239
240 // Document with optional caption
241 if (message.documentMessage) {
242 return message.documentMessage.caption || '';
243 }
244
245 // Voice/Audio message
246 if (message.audioMessage) {
247 return `[Voice Message]`;
248 }
249
250 return null;
251 }
252
253 async sendMessage(to: string, text: string): Promise<void> {
254 if (!this.sock) {
255 throw new Error('Not connected');
256 }
257
258 await this.sock.sendMessage(to, { text });
259 }
260
261 async sendMedia(
262 to: string,
263 filePath: string,
264 mimetype: string,
265 caption?: string,
266 fileName?: string,
267 ): Promise<void> {
268 if (!this.sock) {
269 throw new Error('Not connected');
270 }
271
272 const buffer = await readFile(filePath);
273 const category = mimetype.split('/')[0];
274
275 if (category === 'image') {
276 await this.sock.sendMessage(to, { image: buffer, caption: caption || undefined, mimetype });
277 } else if (category === 'video') {
278 await this.sock.sendMessage(to, { video: buffer, caption: caption || undefined, mimetype });
279 } else if (category === 'audio') {
280 await this.sock.sendMessage(to, { audio: buffer, mimetype });
281 } else {
282 const name = fileName || basename(filePath);
283 await this.sock.sendMessage(to, { document: buffer, mimetype, fileName: name });
284 }
285 }
286
287 async disconnect(): Promise<void> {
288 if (this.sock) {
289 this.sock.end(undefined);
290 this.sock = null;
291 }
292 }
293 }
294
294 lines TYPESCRIPT