@plosson/agentio 0.4.2 → 0.4.3

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.
@@ -0,0 +1,723 @@
1
+ import makeWASocket, {
2
+ DisconnectReason,
3
+ WASocket,
4
+ ConnectionState,
5
+ WAMessage,
6
+ MessageUpsertType,
7
+ jidNormalizedUser,
8
+ isJidGroup,
9
+ getContentType,
10
+ downloadMediaMessage,
11
+ } from '@whiskeysockets/baileys';
12
+ import { join } from 'path';
13
+ import { mkdir } from 'fs/promises';
14
+ import { existsSync } from 'fs';
15
+ import type { ServiceName } from '../../types/config';
16
+ import type {
17
+ WhatsAppCredentials,
18
+ WhatsAppGroup,
19
+ WhatsAppGroupParticipant,
20
+ WhatsAppParticipantAction,
21
+ } from '../../types/whatsapp';
22
+ import { CONFIG_DIR } from '../../config/config-manager';
23
+ import { BaseAdapter, type AdapterInboundMessage, type AdapterOutboundMessage, type SendResult, type ConnectionState as AdapterConnectionState } from './types';
24
+ import { useSQLiteAuthState, hasAuthState } from './whatsapp-auth';
25
+
26
+ // Media storage directory
27
+ const MEDIA_DIR = join(CONFIG_DIR, 'media');
28
+
29
+ // Ensure media directory exists
30
+ async function ensureMediaDir(): Promise<void> {
31
+ if (!existsSync(MEDIA_DIR)) {
32
+ await mkdir(MEDIA_DIR, { recursive: true });
33
+ }
34
+ }
35
+
36
+ // Get file extension for media type
37
+ function getMediaExtension(mediaType: string, mimeType?: string): string {
38
+ if (mimeType) {
39
+ const ext = mimeType.split('/')[1]?.split(';')[0];
40
+ if (ext) return `.${ext}`;
41
+ }
42
+ switch (mediaType) {
43
+ case 'image': return '.jpg';
44
+ case 'video': return '.mp4';
45
+ case 'audio': return '.ogg';
46
+ case 'document': return '.bin';
47
+ default: return '.bin';
48
+ }
49
+ }
50
+
51
+ interface ProfileConnection {
52
+ socket: WASocket | null;
53
+ credentials: WhatsAppCredentials;
54
+ authState: Awaited<ReturnType<typeof useSQLiteAuthState>> | null;
55
+ qrCode: string | null;
56
+ shouldStop: boolean;
57
+ reconnectAttempts: number;
58
+ }
59
+
60
+ // Extract phone number from JID
61
+ function jidToPhone(jid: string): string {
62
+ const normalized = jidNormalizedUser(jid);
63
+ return normalized.split('@')[0];
64
+ }
65
+
66
+ // Convert phone number to JID
67
+ function phoneToJid(phone: string): string {
68
+ // Remove any non-digit characters except leading +
69
+ const cleaned = phone.replace(/[^\d]/g, '');
70
+ return `${cleaned}@s.whatsapp.net`;
71
+ }
72
+
73
+ export class WhatsAppAdapter extends BaseAdapter {
74
+ readonly service: ServiceName = 'whatsapp';
75
+
76
+ private profiles: Map<string, ProfileConnection> = new Map();
77
+
78
+ async connect(profile: string, credentials: unknown): Promise<void> {
79
+ const creds = credentials as WhatsAppCredentials;
80
+
81
+ // Stop existing connection if any
82
+ await this.disconnect(profile);
83
+
84
+ const connection: ProfileConnection = {
85
+ socket: null,
86
+ credentials: creds,
87
+ authState: null,
88
+ qrCode: null,
89
+ shouldStop: false,
90
+ reconnectAttempts: 0,
91
+ };
92
+
93
+ this.profiles.set(profile, connection);
94
+
95
+ try {
96
+ // Initialize auth state from SQLite
97
+ const authState = await useSQLiteAuthState(profile);
98
+ connection.authState = authState;
99
+
100
+ await this.createSocket(profile);
101
+ } catch (error) {
102
+ const message = error instanceof Error ? error.message : 'Connection failed';
103
+ this.setConnected(profile, false, message);
104
+ this.profiles.delete(profile);
105
+ throw error;
106
+ }
107
+ }
108
+
109
+ private async createSocket(profile: string): Promise<void> {
110
+ const connection = this.profiles.get(profile);
111
+ if (!connection || !connection.authState) return;
112
+
113
+ const { state, saveCreds } = connection.authState;
114
+
115
+ // Create a silent logger that satisfies Baileys' requirements
116
+ const silentLogger: any = {
117
+ level: 'silent',
118
+ trace: () => {},
119
+ debug: () => {},
120
+ info: () => {},
121
+ warn: console.warn,
122
+ error: console.error,
123
+ fatal: console.error,
124
+ };
125
+ // child() must return a logger with all methods
126
+ silentLogger.child = () => silentLogger;
127
+
128
+ // Create socket with auth state
129
+ const socket = makeWASocket({
130
+ auth: state,
131
+ printQRInTerminal: false, // We'll handle QR ourselves
132
+ browser: ['agentio', 'Chrome', '120.0.0'],
133
+ logger: silentLogger,
134
+ });
135
+
136
+ connection.socket = socket;
137
+
138
+ // Handle connection updates
139
+ socket.ev.on('connection.update', async (update) => {
140
+ await this.handleConnectionUpdate(profile, update);
141
+ });
142
+
143
+ // Handle credential updates
144
+ socket.ev.on('creds.update', saveCreds);
145
+
146
+ // Handle incoming messages
147
+ socket.ev.on('messages.upsert', (upsert) => {
148
+ this.handleMessagesUpsert(profile, upsert);
149
+ });
150
+ }
151
+
152
+ private async handleConnectionUpdate(profile: string, update: Partial<ConnectionState>): Promise<void> {
153
+ const connection = this.profiles.get(profile);
154
+ if (!connection) return;
155
+
156
+ const { connection: connState, lastDisconnect, qr } = update;
157
+
158
+ // Handle QR code
159
+ if (qr) {
160
+ connection.qrCode = qr;
161
+ console.log(`[whatsapp:${profile}] Scan QR code to connect`);
162
+ // In a real implementation, you'd want to emit this to a pairing endpoint
163
+ }
164
+
165
+ if (connState === 'open') {
166
+ connection.qrCode = null;
167
+ connection.reconnectAttempts = 0;
168
+ this.setConnected(profile, true);
169
+
170
+ // Update credentials with phone number if available
171
+ const user = connection.socket?.user;
172
+ if (user) {
173
+ connection.credentials.phoneNumber = jidToPhone(user.id);
174
+ connection.credentials.displayName = user.name || user.verifiedName;
175
+ connection.credentials.paired = true;
176
+ connection.credentials.lastConnected = Date.now();
177
+ }
178
+
179
+ console.log(`[whatsapp:${profile}] Connected as ${connection.credentials.phoneNumber || 'unknown'}`);
180
+ }
181
+
182
+ if (connState === 'close') {
183
+ const statusCode = (lastDisconnect?.error as any)?.output?.statusCode;
184
+ const shouldReconnect = statusCode !== DisconnectReason.loggedOut;
185
+
186
+ if (statusCode === DisconnectReason.loggedOut) {
187
+ console.log(`[whatsapp:${profile}] Logged out, clearing session`);
188
+ connection.credentials.paired = false;
189
+ if (connection.authState) {
190
+ await connection.authState.clearState();
191
+ }
192
+ this.setConnected(profile, false, 'Logged out');
193
+ } else if (shouldReconnect && !connection.shouldStop) {
194
+ connection.reconnectAttempts++;
195
+ const delay = Math.min(connection.reconnectAttempts * 2000, 30000);
196
+ console.log(`[whatsapp:${profile}] Reconnecting in ${delay / 1000}s...`);
197
+ this.setConnected(profile, false, 'Reconnecting...');
198
+
199
+ setTimeout(() => {
200
+ if (!connection.shouldStop) {
201
+ this.createSocket(profile);
202
+ }
203
+ }, delay);
204
+ } else {
205
+ this.setConnected(profile, false, 'Disconnected');
206
+ }
207
+ }
208
+ }
209
+
210
+ private handleMessagesUpsert(profile: string, upsert: { messages: WAMessage[]; type: MessageUpsertType }): void {
211
+ const connection = this.profiles.get(profile);
212
+ if (!connection) return;
213
+
214
+ // Only process new messages (not history sync)
215
+ if (upsert.type !== 'notify') return;
216
+
217
+ for (const msg of upsert.messages) {
218
+ // Skip messages from self
219
+ if (msg.key.fromMe) continue;
220
+
221
+ // Skip status broadcasts
222
+ if (msg.key.remoteJid === 'status@broadcast') continue;
223
+
224
+ // Process message asynchronously (for media download)
225
+ this.processMessage(profile, msg, connection.socket).catch((error) => {
226
+ console.error(`[whatsapp:${profile}] Error processing message:`, error);
227
+ });
228
+ }
229
+ }
230
+
231
+ private async processMessage(profile: string, msg: WAMessage, socket: WASocket | null): Promise<void> {
232
+ const message = await this.parseMessage(profile, msg, socket);
233
+ if (message) {
234
+ this.emitMessage(profile, message);
235
+ }
236
+ }
237
+
238
+ private async parseMessage(profile: string, msg: WAMessage, socket: WASocket | null): Promise<AdapterInboundMessage | null> {
239
+ const remoteJid = msg.key.remoteJid;
240
+ if (!remoteJid) return null;
241
+
242
+ const messageContent = msg.message;
243
+ if (!messageContent) return null;
244
+
245
+ // Determine sender
246
+ let senderId = remoteJid;
247
+ let senderName: string | undefined;
248
+
249
+ if (isJidGroup(remoteJid)) {
250
+ // Group message - get actual sender
251
+ senderId = msg.key.participant || remoteJid;
252
+ senderName = msg.pushName ?? undefined;
253
+ } else {
254
+ // Private message
255
+ senderName = msg.pushName ?? undefined;
256
+ }
257
+
258
+ // Extract message content
259
+ let content: string | undefined;
260
+ let mediaType: AdapterInboundMessage['mediaType'];
261
+ let mediaPath: string | undefined;
262
+ let mimeType: string | undefined;
263
+
264
+ const contentType = getContentType(messageContent);
265
+
266
+ switch (contentType) {
267
+ case 'conversation':
268
+ content = messageContent.conversation || undefined;
269
+ break;
270
+ case 'extendedTextMessage':
271
+ content = messageContent.extendedTextMessage?.text || undefined;
272
+ break;
273
+ case 'imageMessage':
274
+ mediaType = 'image';
275
+ content = messageContent.imageMessage?.caption || undefined;
276
+ mimeType = messageContent.imageMessage?.mimetype || undefined;
277
+ break;
278
+ case 'videoMessage':
279
+ mediaType = 'video';
280
+ content = messageContent.videoMessage?.caption || undefined;
281
+ mimeType = messageContent.videoMessage?.mimetype || undefined;
282
+ break;
283
+ case 'audioMessage':
284
+ mediaType = 'audio';
285
+ mimeType = messageContent.audioMessage?.mimetype || undefined;
286
+ break;
287
+ case 'documentMessage':
288
+ mediaType = 'document';
289
+ content = messageContent.documentMessage?.caption || undefined;
290
+ mimeType = messageContent.documentMessage?.mimetype || undefined;
291
+ break;
292
+ default:
293
+ // Unsupported message type
294
+ return null;
295
+ }
296
+
297
+ // Download media if present
298
+ if (mediaType && socket) {
299
+ try {
300
+ await ensureMediaDir();
301
+ const buffer = await downloadMediaMessage(
302
+ msg,
303
+ 'buffer',
304
+ {},
305
+ {
306
+ logger: { level: 'silent', child: () => ({ level: 'silent' }) } as any,
307
+ reuploadRequest: socket.updateMediaMessage,
308
+ }
309
+ );
310
+
311
+ if (buffer) {
312
+ const extension = getMediaExtension(mediaType, mimeType);
313
+ const filename = `${msg.key.id || Date.now()}${extension}`;
314
+ mediaPath = join(MEDIA_DIR, filename);
315
+ await Bun.write(mediaPath, buffer);
316
+ console.log(`[whatsapp:${profile}] Downloaded media: ${filename}`);
317
+ }
318
+ } catch (error) {
319
+ console.error(`[whatsapp:${profile}] Failed to download media:`, error);
320
+ // Continue without media - message still gets stored
321
+ }
322
+ }
323
+
324
+ return {
325
+ conversationId: remoteJid,
326
+ platformId: msg.key.id || `${Date.now()}`,
327
+ senderId,
328
+ senderName,
329
+ senderHandle: jidToPhone(senderId),
330
+ content,
331
+ mediaType,
332
+ mediaUrl: mediaPath, // Store local path as mediaUrl
333
+ receivedAt: (msg.messageTimestamp as number) * 1000 || Date.now(),
334
+ replyToId: messageContent.extendedTextMessage?.contextInfo?.stanzaId ?? undefined,
335
+ metadata: {
336
+ isGroup: isJidGroup(remoteJid),
337
+ pushName: msg.pushName,
338
+ mimeType,
339
+ },
340
+ };
341
+ }
342
+
343
+ async disconnect(profile: string): Promise<void> {
344
+ const connection = this.profiles.get(profile);
345
+ if (!connection) return;
346
+
347
+ connection.shouldStop = true;
348
+
349
+ if (connection.socket) {
350
+ try {
351
+ connection.socket.end(undefined);
352
+ } catch {
353
+ // Ignore disconnect errors
354
+ }
355
+ }
356
+
357
+ this.profiles.delete(profile);
358
+ this.connections.delete(profile);
359
+ console.log(`[whatsapp:${profile}] Disconnected`);
360
+ }
361
+
362
+ async disconnectAll(): Promise<void> {
363
+ const profiles = Array.from(this.profiles.keys());
364
+ await Promise.all(profiles.map((p) => this.disconnect(p)));
365
+ }
366
+
367
+ async send(profile: string, message: AdapterOutboundMessage): Promise<SendResult> {
368
+ const connection = this.profiles.get(profile);
369
+ if (!connection || !connection.socket) {
370
+ return { success: false, error: 'Profile not connected' };
371
+ }
372
+
373
+ try {
374
+ // Determine recipient JID
375
+ let jid = message.conversationId;
376
+ if (!jid.includes('@')) {
377
+ jid = phoneToJid(jid);
378
+ }
379
+
380
+ let result;
381
+
382
+ // Handle media attachments
383
+ if (message.mediaPath) {
384
+ const file = Bun.file(message.mediaPath);
385
+ if (!await file.exists()) {
386
+ return { success: false, error: `File not found: ${message.mediaPath}` };
387
+ }
388
+
389
+ const buffer = Buffer.from(await file.arrayBuffer());
390
+ const mimeType = file.type || 'application/octet-stream';
391
+ const filename = message.mediaPath.split('/').pop() || 'file';
392
+
393
+ switch (message.mediaType) {
394
+ case 'image':
395
+ result = await connection.socket.sendMessage(jid, {
396
+ image: buffer,
397
+ caption: message.content,
398
+ mimetype: mimeType,
399
+ });
400
+ break;
401
+ case 'video':
402
+ result = await connection.socket.sendMessage(jid, {
403
+ video: buffer,
404
+ caption: message.content,
405
+ mimetype: mimeType,
406
+ });
407
+ break;
408
+ case 'audio':
409
+ result = await connection.socket.sendMessage(jid, {
410
+ audio: buffer,
411
+ mimetype: mimeType,
412
+ ptt: mimeType.includes('ogg'), // Voice note if ogg
413
+ });
414
+ break;
415
+ case 'document':
416
+ default:
417
+ result = await connection.socket.sendMessage(jid, {
418
+ document: buffer,
419
+ fileName: filename,
420
+ caption: message.content,
421
+ mimetype: mimeType,
422
+ });
423
+ break;
424
+ }
425
+ } else if (message.content) {
426
+ // Text-only message
427
+ result = await connection.socket.sendMessage(jid, { text: message.content });
428
+ } else {
429
+ return { success: false, error: 'No content or media to send' };
430
+ }
431
+
432
+ return {
433
+ success: true,
434
+ platformId: result?.key?.id ?? undefined,
435
+ };
436
+ } catch (error) {
437
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
438
+ return { success: false, error: errorMessage };
439
+ }
440
+ }
441
+
442
+ /**
443
+ * Get the current QR code for pairing (if available)
444
+ */
445
+ getQRCode(profile: string): string | null {
446
+ return this.profiles.get(profile)?.qrCode ?? null;
447
+ }
448
+
449
+ /**
450
+ * Check if a profile needs pairing (no existing session)
451
+ */
452
+ needsPairing(profile: string): boolean {
453
+ return !hasAuthState(profile);
454
+ }
455
+
456
+ /**
457
+ * Get connection state with extra WhatsApp-specific info
458
+ */
459
+ getWhatsAppState(profile: string): AdapterConnectionState & {
460
+ qrCode?: string;
461
+ phoneNumber?: string;
462
+ paired: boolean;
463
+ } {
464
+ const connection = this.profiles.get(profile);
465
+ const baseState = this.getConnectionState(profile);
466
+
467
+ return {
468
+ ...baseState,
469
+ qrCode: connection?.qrCode ?? undefined,
470
+ phoneNumber: connection?.credentials.phoneNumber,
471
+ paired: connection?.credentials.paired ?? false,
472
+ };
473
+ }
474
+
475
+ // ============ GROUP OPERATIONS ============
476
+
477
+ /**
478
+ * List all groups the user is participating in
479
+ */
480
+ async listGroups(profile: string): Promise<WhatsAppGroup[]> {
481
+ const connection = this.profiles.get(profile);
482
+ if (!connection?.socket) {
483
+ throw new Error('Profile not connected');
484
+ }
485
+
486
+ const groups = await connection.socket.groupFetchAllParticipating();
487
+ const myJid = connection.socket.user?.id;
488
+
489
+ return Object.values(groups).map((g) => {
490
+ const myParticipant = g.participants?.find((p) => p.id === myJid);
491
+ return {
492
+ id: g.id,
493
+ name: g.subject,
494
+ description: g.desc ?? undefined,
495
+ owner: g.owner ?? undefined,
496
+ creation: g.creation,
497
+ participantCount: g.participants?.length ?? 0,
498
+ isAdmin: myParticipant?.admin === 'admin' || myParticipant?.admin === 'superadmin',
499
+ isSuperAdmin: myParticipant?.admin === 'superadmin',
500
+ announce: g.announce ?? false,
501
+ restrict: g.restrict ?? false,
502
+ };
503
+ });
504
+ }
505
+
506
+ /**
507
+ * Get detailed group information
508
+ */
509
+ async getGroup(profile: string, groupId: string): Promise<WhatsAppGroup> {
510
+ const connection = this.profiles.get(profile);
511
+ if (!connection?.socket) {
512
+ throw new Error('Profile not connected');
513
+ }
514
+
515
+ const metadata = await connection.socket.groupMetadata(groupId);
516
+ const myJid = connection.socket.user?.id;
517
+ const myParticipant = metadata.participants?.find((p) => p.id === myJid);
518
+
519
+ const participants: WhatsAppGroupParticipant[] = metadata.participants.map((p) => ({
520
+ id: p.id,
521
+ phone: jidToPhone(p.id),
522
+ isAdmin: p.admin === 'admin' || p.admin === 'superadmin',
523
+ isSuperAdmin: p.admin === 'superadmin',
524
+ }));
525
+
526
+ return {
527
+ id: metadata.id,
528
+ name: metadata.subject,
529
+ description: metadata.desc ?? undefined,
530
+ owner: metadata.owner ?? undefined,
531
+ creation: metadata.creation,
532
+ participantCount: metadata.participants.length,
533
+ participants,
534
+ isAdmin: myParticipant?.admin === 'admin' || myParticipant?.admin === 'superadmin',
535
+ isSuperAdmin: myParticipant?.admin === 'superadmin',
536
+ announce: metadata.announce ?? false,
537
+ restrict: metadata.restrict ?? false,
538
+ };
539
+ }
540
+
541
+ /**
542
+ * Create a new group
543
+ */
544
+ async createGroup(profile: string, name: string, participants: string[], picturePath?: string): Promise<WhatsAppGroup> {
545
+ const connection = this.profiles.get(profile);
546
+ if (!connection?.socket) {
547
+ throw new Error('Profile not connected');
548
+ }
549
+
550
+ const jids = participants.map((p) => (p.includes('@') ? p : phoneToJid(p)));
551
+ const result = await connection.socket.groupCreate(name, jids);
552
+
553
+ // Set profile picture if provided
554
+ if (picturePath) {
555
+ await this.updateGroupPicture(profile, result.id, picturePath);
556
+ }
557
+
558
+ return this.getGroup(profile, result.id);
559
+ }
560
+
561
+ /**
562
+ * Update group profile picture
563
+ */
564
+ async updateGroupPicture(profile: string, groupId: string, picturePath: string): Promise<void> {
565
+ const connection = this.profiles.get(profile);
566
+ if (!connection?.socket) {
567
+ throw new Error('Profile not connected');
568
+ }
569
+
570
+ const file = Bun.file(picturePath);
571
+ if (!await file.exists()) {
572
+ throw new Error(`File not found: ${picturePath}`);
573
+ }
574
+
575
+ const buffer = Buffer.from(await file.arrayBuffer());
576
+ await connection.socket.updateProfilePicture(groupId, buffer);
577
+ }
578
+
579
+ /**
580
+ * Update group subject (name)
581
+ */
582
+ async updateGroupSubject(profile: string, groupId: string, subject: string): Promise<void> {
583
+ const connection = this.profiles.get(profile);
584
+ if (!connection?.socket) {
585
+ throw new Error('Profile not connected');
586
+ }
587
+
588
+ await connection.socket.groupUpdateSubject(groupId, subject);
589
+ }
590
+
591
+ /**
592
+ * Update group description
593
+ */
594
+ async updateGroupDescription(profile: string, groupId: string, description: string): Promise<void> {
595
+ const connection = this.profiles.get(profile);
596
+ if (!connection?.socket) {
597
+ throw new Error('Profile not connected');
598
+ }
599
+
600
+ await connection.socket.groupUpdateDescription(groupId, description);
601
+ }
602
+
603
+ /**
604
+ * Update group participants (add, remove, promote, demote)
605
+ */
606
+ async updateParticipants(
607
+ profile: string,
608
+ groupId: string,
609
+ participants: string[],
610
+ action: WhatsAppParticipantAction
611
+ ): Promise<{ participant: string; status: string }[]> {
612
+ const connection = this.profiles.get(profile);
613
+ if (!connection?.socket) {
614
+ throw new Error('Profile not connected');
615
+ }
616
+
617
+ const jids = participants.map((p) => (p.includes('@') ? p : phoneToJid(p)));
618
+ const results = await connection.socket.groupParticipantsUpdate(groupId, jids, action);
619
+
620
+ return results.map((r) => ({
621
+ participant: r.jid ?? 'unknown',
622
+ status: r.status,
623
+ }));
624
+ }
625
+
626
+ /**
627
+ * Leave a group
628
+ */
629
+ async leaveGroup(profile: string, groupId: string): Promise<void> {
630
+ const connection = this.profiles.get(profile);
631
+ if (!connection?.socket) {
632
+ throw new Error('Profile not connected');
633
+ }
634
+
635
+ await connection.socket.groupLeave(groupId);
636
+ }
637
+
638
+ /**
639
+ * Get group invite code
640
+ */
641
+ async getGroupInviteCode(profile: string, groupId: string): Promise<string> {
642
+ const connection = this.profiles.get(profile);
643
+ if (!connection?.socket) {
644
+ throw new Error('Profile not connected');
645
+ }
646
+
647
+ const code = await connection.socket.groupInviteCode(groupId);
648
+ if (!code) {
649
+ throw new Error('Failed to get invite code');
650
+ }
651
+ return code;
652
+ }
653
+
654
+ /**
655
+ * Join a group via invite code
656
+ */
657
+ async joinGroupViaInvite(profile: string, inviteCode: string): Promise<string> {
658
+ const connection = this.profiles.get(profile);
659
+ if (!connection?.socket) {
660
+ throw new Error('Profile not connected');
661
+ }
662
+
663
+ // Extract code from full URL if provided
664
+ let code = inviteCode;
665
+ if (inviteCode.includes('chat.whatsapp.com/')) {
666
+ code = inviteCode.split('chat.whatsapp.com/').pop() || inviteCode;
667
+ }
668
+
669
+ const groupId = await connection.socket.groupAcceptInvite(code);
670
+ if (!groupId) {
671
+ throw new Error('Failed to join group');
672
+ }
673
+ return groupId;
674
+ }
675
+
676
+ /**
677
+ * Resolve group name to JID or JID to name
678
+ * Returns groupId if input looks like a JID, or searches for matching group name
679
+ */
680
+ async resolveGroup(profile: string, nameOrId: string): Promise<{ groupId: string | null; groupName: string | null }> {
681
+ // If it looks like a group JID, get the name
682
+ if (nameOrId.includes('@g.us')) {
683
+ try {
684
+ const group = await this.getGroup(profile, nameOrId);
685
+ return { groupId: nameOrId, groupName: group.name };
686
+ } catch {
687
+ return { groupId: nameOrId, groupName: null };
688
+ }
689
+ }
690
+
691
+ // Otherwise, search for group by name
692
+ const groups = await this.listGroups(profile);
693
+ const lowerName = nameOrId.toLowerCase();
694
+
695
+ // Exact match first
696
+ const exactMatch = groups.find((g) => g.name.toLowerCase() === lowerName);
697
+ if (exactMatch) {
698
+ return { groupId: exactMatch.id, groupName: exactMatch.name };
699
+ }
700
+
701
+ // Partial match
702
+ const partialMatch = groups.find((g) => g.name.toLowerCase().includes(lowerName));
703
+ if (partialMatch) {
704
+ return { groupId: partialMatch.id, groupName: partialMatch.name };
705
+ }
706
+
707
+ return { groupId: null, groupName: null };
708
+ }
709
+
710
+ /**
711
+ * Get cached group name for a JID (for display purposes)
712
+ * This is a quick lookup that doesn't make API calls
713
+ */
714
+ async getGroupNameCached(profile: string, groupId: string): Promise<string | null> {
715
+ try {
716
+ const groups = await this.listGroups(profile);
717
+ const group = groups.find((g) => g.id === groupId);
718
+ return group?.name ?? null;
719
+ } catch {
720
+ return null;
721
+ }
722
+ }
723
+ }