whatsapp-pi 1.0.54 → 1.0.56

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/README.md CHANGED
@@ -169,3 +169,4 @@ npm test
169
169
  - **Session Handling**: Saved state, allow list, and startup reconnects are restored automatically when available.
170
170
  - **Intelligent Message Filtering**: Messages ending with `π` are ignored to prevent bot loops.
171
171
  - **Storage Management**: Persistent data lives under `.pi-data/` plus the recents store in the user home directory.
172
+ - **Improved Test Coverage (v1.0.56)**: Added unit tests for the `message_end` auto-reply handler, covering the happy path, disconnected guard, role guard, send failure, thrown exceptions, and the `send_wa_message` dedup flag. Fixed a Windows path separator bug in the recents service test suite.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "whatsapp-pi",
3
- "version": "1.0.54",
3
+ "version": "1.0.56",
4
4
  "type": "module",
5
5
  "description": "WhatsApp integration extension for Pi",
6
6
  "main": "whatsapp-pi.ts",
package/src/i18n.ts CHANGED
@@ -80,6 +80,11 @@ const fallback = {
80
80
  "menu.allowed.contact.addAlias": "Add Alias",
81
81
  "menu.allowed.contact.removeNumber": "Remove Contact",
82
82
  "menu.allowed.contact.back": "Back",
83
+ "menu.allowed.contact.addNumber": "Add Number",
84
+ "menu.allowed.contact.removeSendNumber": "Remove Number",
85
+ "menu.allowed.contact.enterNumber": "Enter send number for {displayName}:",
86
+ "menu.allowed.contact.numberAdded": "Send number added for {displayName}",
87
+ "menu.allowed.contact.numberRemoved": "Send number removed for {displayName}",
83
88
  "menu.allowed.enterAlias": "Enter alias for {number}:",
84
89
  "menu.allowed.pleaseEnterAlias": "Please enter an alias.",
85
90
  "menu.allowed.aliasAdded": "Alias added for {number}",
@@ -138,6 +143,8 @@ const fallback = {
138
143
  "menu.recents.history.received": "Received",
139
144
  "menu.recents.history.noText": "[No text]",
140
145
  "menu.recents.history.messageTitle": "Message • {displayName}",
146
+ "menu.recents.grouped.contacts": "({count} contacts)",
147
+ "menu.recents.grouped.title": "Recents • ({count} contacts)",
141
148
  "message.detail.defaultTitle": "Message Details",
142
149
  "message.detail.noReadableText": "[No readable text available]",
143
150
  "message.detail.messageId": "Message ID",
@@ -289,6 +296,11 @@ const translations: Record<Locale, Partial<Record<Key, string>>> = {
289
296
  "menu.allowed.contact.addAlias": "Adicionar apelido",
290
297
  "menu.allowed.contact.removeNumber": "Remover contato",
291
298
  "menu.allowed.contact.back": "Voltar",
299
+ "menu.allowed.contact.addNumber": "Adicionar número",
300
+ "menu.allowed.contact.removeSendNumber": "Remover número",
301
+ "menu.allowed.contact.enterNumber": "Digite o número de envio para {displayName}:",
302
+ "menu.allowed.contact.numberAdded": "Número de envio adicionado para {displayName}",
303
+ "menu.allowed.contact.numberRemoved": "Número de envio removido para {displayName}",
292
304
  "menu.allowed.enterAlias": "Digite apelido para {number}:",
293
305
  "menu.allowed.pleaseEnterAlias": "Digite um apelido.",
294
306
  "menu.allowed.aliasAdded": "Apelido adicionado para {number}",
@@ -347,6 +359,8 @@ const translations: Record<Locale, Partial<Record<Key, string>>> = {
347
359
  "menu.recents.history.received": "Recebida",
348
360
  "menu.recents.history.noText": "[Sem texto]",
349
361
  "menu.recents.history.messageTitle": "Mensagem • {displayName}",
362
+ "menu.recents.grouped.contacts": "({count} contactos)",
363
+ "menu.recents.grouped.title": "Recentes • ({count} contactos)",
350
364
  "message.detail.defaultTitle": "Detalhes da mensagem",
351
365
  "message.detail.noReadableText": "[No readable text available]",
352
366
  "message.detail.messageId": "ID da mensagem",
@@ -461,6 +475,11 @@ const translations: Record<Locale, Partial<Record<Key, string>>> = {
461
475
  "menu.allowed.contact.addAlias": "Agregar alias",
462
476
  "menu.allowed.contact.removeNumber": "Quitar contacto",
463
477
  "menu.allowed.contact.back": "Volver",
478
+ "menu.allowed.contact.addNumber": "Agregar número",
479
+ "menu.allowed.contact.removeSendNumber": "Quitar número",
480
+ "menu.allowed.contact.enterNumber": "Ingresa el número de envío para {displayName}:",
481
+ "menu.allowed.contact.numberAdded": "Número de envío agregado para {displayName}",
482
+ "menu.allowed.contact.numberRemoved": "Número de envío eliminado para {displayName}",
464
483
  "menu.allowed.enterAlias": "Ingresa alias para {number}:",
465
484
  "menu.allowed.pleaseEnterAlias": "Ingresa un alias.",
466
485
  "menu.allowed.aliasAdded": "Alias agregado para {number}",
@@ -491,6 +510,8 @@ const translations: Record<Locale, Partial<Record<Key, string>>> = {
491
510
  "menu.recents.history.received": "Recibido",
492
511
  "menu.recents.history.noText": "[Sin texto]",
493
512
  "menu.recents.history.messageTitle": "Mensaje • {displayName}",
513
+ "menu.recents.grouped.contacts": "({count} contactos)",
514
+ "menu.recents.grouped.title": "Recientes • ({count} contactos)",
494
515
  "message.detail.defaultTitle": "Detalles del mensaje",
495
516
  "message.detail.noReadableText": "[No readable text available]",
496
517
  "message.detail.messageId": "ID del mensaje",
@@ -588,6 +609,11 @@ const translations: Record<Locale, Partial<Record<Key, string>>> = {
588
609
  "menu.allowed.contact.addAlias": "Ajouter un alias",
589
610
  "menu.allowed.contact.removeNumber": "Supprimer le contact",
590
611
  "menu.allowed.contact.back": "Retour",
612
+ "menu.allowed.contact.addNumber": "Ajouter un numéro",
613
+ "menu.allowed.contact.removeSendNumber": "Supprimer le numéro",
614
+ "menu.allowed.contact.enterNumber": "Entrez le numéro d'envoi pour {displayName} :",
615
+ "menu.allowed.contact.numberAdded": "Numéro d'envoi ajouté pour {displayName}",
616
+ "menu.allowed.contact.numberRemoved": "Numéro d'envoi supprimé pour {displayName}",
591
617
  "menu.allowed.enterAlias": "Entrez un alias pour {number} :",
592
618
  "menu.allowed.pleaseEnterAlias": "Veuillez saisir un alias.",
593
619
  "menu.allowed.aliasAdded": "Alias ajouté pour {number}",
@@ -618,6 +644,8 @@ const translations: Record<Locale, Partial<Record<Key, string>>> = {
618
644
  "menu.recents.history.received": "Reçu",
619
645
  "menu.recents.history.noText": "[Sans texte]",
620
646
  "menu.recents.history.messageTitle": "Message • {displayName}",
647
+ "menu.recents.grouped.contacts": "({count} contacts)",
648
+ "menu.recents.grouped.title": "Récents • ({count} contacts)",
621
649
  "message.detail.defaultTitle": "Détails du message",
622
650
  "message.detail.noReadableText": "[No readable text available]",
623
651
  "message.detail.messageId": "ID du message",
@@ -125,8 +125,15 @@ export class RecentsService {
125
125
  });
126
126
  }
127
127
 
128
+ private stripSpecialCharacters(text: string): string {
129
+ return text
130
+ .replace(/[\p{Extended_Pictographic}\p{Emoji_Modifier}\p{Regional_Indicator}\u200D\uFE0F]/gu, '')
131
+ .replace(/\s+/g, ' ')
132
+ .trim();
133
+ }
134
+
128
135
  private buildPreview(text: string): string {
129
- const normalized = text.trim().replace(/\s+/g, ' ');
136
+ const normalized = this.stripSpecialCharacters(text);
130
137
  if (normalized.length <= 80) return normalized;
131
138
  return `${normalized.slice(0, 77)}...`;
132
139
  }
@@ -158,7 +165,7 @@ export class RecentsService {
158
165
  if (!senderNumber) return;
159
166
 
160
167
  const normalizedTimestamp = this.normalizeTimestamp(input.timestamp);
161
- const normalizedText = input.text.trim().replace(/\s+/g, ' ');
168
+ const normalizedText = this.stripSpecialCharacters(input.text);
162
169
  if (!normalizedText) return;
163
170
 
164
171
  const existing = this.store.messagesBySender[senderNumber] ?? [];
@@ -181,7 +188,7 @@ export class RecentsService {
181
188
  const summary: RecentConversationSummary = {
182
189
  senderNumber,
183
190
  senderName: input.senderName ?? existingConversation?.senderName,
184
- lastMessagePreview: this.buildPreview(input.text),
191
+ lastMessagePreview: this.buildPreview(normalizedText),
185
192
  lastMessageTime: normalizedTimestamp,
186
193
  lastMessageDirection: input.direction,
187
194
  messageCount: this.store.messagesBySender[senderNumber].length,
@@ -8,6 +8,7 @@ import { t } from '../i18n.js';
8
8
  export interface Contact {
9
9
  number: string;
10
10
  name?: string;
11
+ sendNumber?: string;
11
12
  reactionMode?: ReactionMode;
12
13
  }
13
14
 
@@ -76,7 +77,8 @@ export class SessionManager {
76
77
  const reactionMode = item.reactionMode === 'active' || item.reactionMode === 'passive'
77
78
  ? item.reactionMode
78
79
  : undefined;
79
- return { number: num, name: item.name, reactionMode };
80
+ const sendNumber = typeof item.sendNumber === 'string' ? item.sendNumber : undefined;
81
+ return { number: num, name: item.name, sendNumber, reactionMode };
80
82
  }
81
83
  }
82
84
  return null;
@@ -293,6 +295,20 @@ export class SessionManager {
293
295
  await this.saveConfig();
294
296
  }
295
297
 
298
+ async setContactSendNumber(number: string, sendNumber: string) {
299
+ const contact = this.getAllowedContact(number);
300
+ if (!contact) return;
301
+ contact.sendNumber = sendNumber.trim();
302
+ await this.saveConfig();
303
+ }
304
+
305
+ async removeContactSendNumber(number: string) {
306
+ const contact = this.getAllowedContact(number);
307
+ if (!contact) return;
308
+ delete contact.sendNumber;
309
+ await this.saveConfig();
310
+ }
311
+
296
312
  async setAllowedGroupAlias(groupJid: string, alias: string) {
297
313
  const trimmedAlias = alias.trim();
298
314
  if (!trimmedAlias) {
@@ -107,6 +107,7 @@ export class WhatsAppService {
107
107
  private saveCreds?: () => Promise<void>;
108
108
  private restoreBaileysConsoleFilter?: () => void;
109
109
  private reconnectTimeout?: ReturnType<typeof setTimeout>;
110
+ private intentionalStop = false;
110
111
  private onQRCode?: (qr: string) => void;
111
112
  private onMessage?: (m: MessagesUpsertEvent) => void;
112
113
  private onStatusUpdate?: (status: string) => void;
@@ -254,6 +255,26 @@ export class WhatsAppService {
254
255
  return Math.min(delay, WhatsAppService.MAX_RECONNECT_DELAY_MS);
255
256
  }
256
257
 
258
+ private scheduleReconnect(options: WhatsAppStartOptions) {
259
+ if (this.intentionalStop) return;
260
+ this.isReconnecting = true;
261
+ this.reconnectAttempts++;
262
+ const delay = this.getReconnectDelayMs();
263
+ this.onStatusUpdate?.(t('service.whatsapp.reconnecting'));
264
+ this.clearReconnectTimeout();
265
+ this.reconnectTimeout = setTimeout(async () => {
266
+ this.isReconnecting = false;
267
+ if (this.intentionalStop) return;
268
+ try {
269
+ await this.start(options);
270
+ } catch {
271
+ if (!this.intentionalStop) {
272
+ this.scheduleReconnect(options);
273
+ }
274
+ }
275
+ }, delay);
276
+ }
277
+
257
278
  private cleanupSocket() {
258
279
  this.clearReconnectTimeout();
259
280
 
@@ -322,6 +343,7 @@ export class WhatsAppService {
322
343
  }
323
344
 
324
345
  async start(options: WhatsAppStartOptions = {}) {
346
+ this.intentionalStop = false;
325
347
  if (this.isReconnecting) return;
326
348
  this.onStatusUpdate?.(t('service.whatsapp.connecting'));
327
349
 
@@ -424,6 +446,10 @@ export class WhatsAppService {
424
446
  const isAuthRejected = this.isAuthRejected(statusCode, errorMessage);
425
447
  const shouldTreatAsLoggedOut = isBadMac || isAuthRejected;
426
448
 
449
+ if (this.intentionalStop) {
450
+ return;
451
+ }
452
+
427
453
  if (this.verboseMode) {
428
454
  console.error(t('service.whatsapp.connectionClosed', { statusCode: statusCode ?? 'unknown', shouldReconnect: String(shouldReconnect) }));
429
455
  }
@@ -464,17 +490,9 @@ export class WhatsAppService {
464
490
  }
465
491
 
466
492
  if (shouldReconnect && !this.isReconnecting) {
467
- this.isReconnecting = true;
468
- this.reconnectAttempts++;
469
- const reconnectDelayMs = this.getReconnectDelayMs();
470
- this.onStatusUpdate?.(t('service.whatsapp.reconnecting'));
471
- this.clearReconnectTimeout();
472
493
  await this.saveCreds?.();
473
494
  this.cleanupSocket();
474
- this.reconnectTimeout = setTimeout(() => {
475
- this.isReconnecting = false;
476
- void this.start(options);
477
- }, reconnectDelayMs);
495
+ this.scheduleReconnect(options);
478
496
  } else if (!shouldReconnect) {
479
497
  this.reconnectAttempts = 0;
480
498
  this.sessionManager.setStatus('logged-out');
@@ -706,11 +724,13 @@ export class WhatsAppService {
706
724
  }
707
725
 
708
726
  async logout() {
727
+ this.intentionalStop = true;
709
728
  await this.socket?.logout();
710
729
  await this.sessionManager.deleteAuthState();
711
730
  }
712
731
 
713
732
  async stop() {
733
+ this.intentionalStop = true;
714
734
  try {
715
735
  await this.saveCreds?.();
716
736
  } catch (error) {
@@ -13,6 +13,12 @@ interface HistoryOptionEntry {
13
13
  message: RecentConversationMessage;
14
14
  }
15
15
 
16
+ interface GroupedRecentEntry {
17
+ conversations: RecentConversationSummary[];
18
+ sharedPreview: string;
19
+ sharedTime: number;
20
+ }
21
+
16
22
  export class MenuHandler {
17
23
  private readonly printedAllowedContacts: string[] = [];
18
24
  private readonly printedAllowedGroups: string[] = [];
@@ -137,8 +143,17 @@ export class MenuHandler {
137
143
  const removeAliasLabel = t('menu.allowed.contact.removeAlias');
138
144
  const addAliasLabel = t('menu.allowed.contact.addAlias');
139
145
  const removeNumberLabel = t('menu.allowed.contact.removeNumber');
146
+ const addSendNumberLabel = t('menu.allowed.contact.addNumber');
147
+ const removeSendNumberLabel = t('menu.allowed.contact.removeSendNumber');
140
148
  const backLabel = t('menu.allowed.contact.back');
141
- const options = [historyLabel, sendMessageLabel, printNumberLabel];
149
+ const options = [historyLabel];
150
+ if (contact.sendNumber) {
151
+ options.push(sendMessageLabel);
152
+ options.push(removeSendNumberLabel);
153
+ } else {
154
+ options.push(addSendNumberLabel);
155
+ }
156
+ options.push(printNumberLabel);
142
157
  if (contact.name) {
143
158
  options.push(removeAliasLabel);
144
159
  } else {
@@ -148,6 +163,27 @@ export class MenuHandler {
148
163
 
149
164
  const choice = await ctx.ui.select(title, options);
150
165
 
166
+ if (choice === addSendNumberLabel) {
167
+ const input = await ctx.ui.input(t('menu.allowed.contact.enterNumber', { displayName }));
168
+ const trimmed = input?.trim() || '';
169
+ if (!validatePhoneNumber(trimmed)) {
170
+ ctx.ui.notify(t('menu.allowed.invalidNumber'), 'error');
171
+ await this.manageAllowedContact(ctx, contact);
172
+ return;
173
+ }
174
+ await this.sessionManager.setContactSendNumber(contact.number, trimmed);
175
+ ctx.ui.notify(t('menu.allowed.contact.numberAdded', { displayName }), 'info');
176
+ await this.manageAllowedContact(ctx, { ...contact, sendNumber: trimmed });
177
+ return;
178
+ }
179
+
180
+ if (choice === removeSendNumberLabel) {
181
+ await this.sessionManager.removeContactSendNumber(contact.number);
182
+ ctx.ui.notify(t('menu.allowed.contact.numberRemoved', { displayName }), 'info');
183
+ await this.manageAllowedContact(ctx, { ...contact, sendNumber: undefined });
184
+ return;
185
+ }
186
+
151
187
  if (choice === sendMessageLabel) {
152
188
  await this.sendMessageToAllowedContact(ctx, contact);
153
189
  await this.manageAllowedContact(ctx, contact);
@@ -344,36 +380,56 @@ export class MenuHandler {
344
380
 
345
381
  private async manageRecents(ctx: ExtensionCommandContext) {
346
382
  const recentConversations = await this.recentsService.getRecentConversations();
383
+ const groupedEntries = this.groupRecentConversations(recentConversations);
347
384
  const title = t('menu.recents.title');
348
385
  const backLabel = t('menu.root.back');
386
+ const nextLabel = 'Next';
387
+ const previousLabel = 'Previous';
388
+ const pageSize = 10;
349
389
 
350
- if (recentConversations.length === 0) {
390
+ if (groupedEntries.length === 0) {
351
391
  ctx.ui.notify(t('menu.recents.empty'), 'info');
352
392
  await this.handleCommand(ctx);
353
393
  return;
354
394
  }
355
395
 
356
- const options = [
357
- ...recentConversations.map(conversation => this.formatRecentConversationOption(conversation)),
358
- backLabel
359
- ];
396
+ for (let page = 0; page * pageSize < groupedEntries.length;) {
397
+ const start = page * pageSize;
398
+ const pageEntries = groupedEntries.slice(start, start + pageSize);
399
+ const options = [
400
+ ...pageEntries.map(entry => this.formatGroupedRecentOption(entry)),
401
+ ...(page > 0 ? [previousLabel] : []),
402
+ ...(start + pageSize < groupedEntries.length ? [nextLabel] : []),
403
+ backLabel
404
+ ];
360
405
 
361
- const choice = await ctx.ui.select(title, options);
362
- if (!choice || choice === backLabel) {
363
- await this.handleCommand(ctx);
364
- return;
365
- }
406
+ const choice = await ctx.ui.select(title, options);
407
+ if (!choice || choice === backLabel) {
408
+ await this.handleCommand(ctx);
409
+ return;
410
+ }
366
411
 
367
- const selectedConversation = recentConversations.find(conversation =>
368
- this.formatRecentConversationOption(conversation) === choice
369
- );
412
+ if (choice === nextLabel) {
413
+ page += 1;
414
+ continue;
415
+ }
416
+
417
+ if (choice === previousLabel) {
418
+ page = Math.max(0, page - 1);
419
+ continue;
420
+ }
421
+
422
+ const selectedEntry = pageEntries.find(entry =>
423
+ this.formatGroupedRecentOption(entry) === choice
424
+ );
370
425
 
371
- if (!selectedConversation) {
372
- await this.manageRecents(ctx);
426
+ if (!selectedEntry) {
427
+ return;
428
+ }
429
+
430
+ await this.manageRecentConversation(ctx, selectedEntry.conversations[0]);
373
431
  return;
374
432
  }
375
-
376
- await this.manageRecentConversation(ctx, selectedConversation);
377
433
  }
378
434
 
379
435
  private async manageRecentConversation(ctx: ExtensionCommandContext, conversation: RecentConversationSummary) {
@@ -387,7 +443,6 @@ export class MenuHandler {
387
443
  const allowContactLabel = isGroup
388
444
  ? t('menu.recents.contact.allowGroup')
389
445
  : t('menu.recents.contact.allowNumber');
390
- const sendMessageLabel = t('menu.recents.contact.sendMessage');
391
446
  const removeAliasLabel = t('menu.recents.contact.removeAlias');
392
447
  const backLabel = t('menu.recents.contact.back');
393
448
  const options: string[] = [historyLabel];
@@ -396,8 +451,6 @@ export class MenuHandler {
396
451
  options.push(allowContactLabel);
397
452
  }
398
453
 
399
- options.push(sendMessageLabel);
400
-
401
454
  if (allowedContact?.name) {
402
455
  options.push(removeAliasLabel);
403
456
  }
@@ -434,12 +487,6 @@ export class MenuHandler {
434
487
  return;
435
488
  }
436
489
 
437
- if (choice === sendMessageLabel) {
438
- await this.sendMessageFromRecents(ctx, conversation);
439
- await this.manageRecentConversation(ctx, conversation);
440
- return;
441
- }
442
-
443
490
  if (choice === historyLabel) {
444
491
  await this.showConversationHistory(ctx, conversation);
445
492
  await this.manageRecentConversation(ctx, conversation);
@@ -449,19 +496,10 @@ export class MenuHandler {
449
496
  await this.manageRecents(ctx);
450
497
  }
451
498
 
452
- private async sendMessageFromRecents(ctx: ExtensionCommandContext, conversation: RecentConversationSummary) {
453
- await this.sendPromptedMenuMessage(ctx, {
454
- displayName: this.getConversationDisplayName(conversation),
455
- senderNumber: conversation.senderNumber,
456
- senderName: conversation.senderName,
457
- appendPiSuffix: false
458
- });
459
- }
460
-
461
499
  private async sendMessageToAllowedContact(ctx: ExtensionCommandContext, contact: Contact) {
462
500
  await this.sendPromptedMenuMessage(ctx, {
463
501
  displayName: this.formatAllowedContactOption(contact),
464
- senderNumber: contact.number,
502
+ senderNumber: contact.sendNumber!,
465
503
  senderName: contact.name,
466
504
  appendPiSuffix: true
467
505
  });
@@ -559,44 +597,66 @@ export class MenuHandler {
559
597
  return;
560
598
  }
561
599
 
562
- const historyOptions = this.buildHistoryOptions(this.sortHistoryByMostRecent(history));
563
- const choice = await ctx.ui.select(t('menu.recents.history.title', { displayName }), [
564
- ...historyOptions.map(option => option.label),
565
- t('menu.root.back')
566
- ]);
600
+ const sortedHistory = this.sortHistoryByMostRecent(history);
601
+ const pageSize = 10;
602
+ const backLabel = t('menu.root.back');
603
+ const nextLabel = 'Next';
604
+ const previousLabel = 'Previous';
605
+
606
+ for (let page = 0; page * pageSize < sortedHistory.length;) {
607
+ const start = page * pageSize;
608
+ const pageHistory = sortedHistory.slice(start, start + pageSize);
609
+ const historyOptions = this.buildHistoryOptions(pageHistory);
610
+ const choice = await ctx.ui.select(t('menu.recents.history.title', { displayName }), [
611
+ ...historyOptions.map(option => option.label),
612
+ ...(page > 0 ? [previousLabel] : []),
613
+ ...(start + pageSize < sortedHistory.length ? [nextLabel] : []),
614
+ backLabel
615
+ ]);
616
+
617
+ if (!choice || choice === backLabel) {
618
+ return;
619
+ }
567
620
 
568
- if (!choice || choice === t('menu.root.back')) {
569
- return;
570
- }
621
+ if (choice === nextLabel) {
622
+ page += 1;
623
+ continue;
624
+ }
571
625
 
572
- const selectedMessage = this.resolveHistorySelection(choice, historyOptions);
573
- if (!selectedMessage) {
574
- return;
575
- }
626
+ if (choice === previousLabel) {
627
+ page = Math.max(0, page - 1);
628
+ continue;
629
+ }
576
630
 
577
- const detailAction = await showMessageDetailView(ctx, {
578
- title: t('menu.recents.history.messageTitle', { displayName }),
579
- messageId: selectedMessage.messageId,
580
- senderNumber: selectedMessage.senderNumber,
581
- senderName,
582
- text: selectedMessage.text,
583
- direction: selectedMessage.direction,
584
- timestamp: selectedMessage.timestamp
585
- });
631
+ const selectedMessage = this.resolveHistorySelection(choice, historyOptions);
632
+ if (!selectedMessage) {
633
+ return;
634
+ }
586
635
 
587
- if (detailAction === 'reply') {
588
- await showMessageReplyView(ctx, {
589
- selectedMessage: {
590
- messageId: selectedMessage.messageId,
591
- senderNumber: selectedMessage.senderNumber,
592
- senderName,
593
- text: selectedMessage.text,
594
- direction: selectedMessage.direction,
595
- timestamp: selectedMessage.timestamp
596
- },
597
- whatsappService: this.whatsappService,
598
- recentsService: this.recentsService
636
+ const detailAction = await showMessageDetailView(ctx, {
637
+ title: t('menu.recents.history.messageTitle', { displayName }),
638
+ messageId: selectedMessage.messageId,
639
+ senderNumber: selectedMessage.senderNumber,
640
+ senderName,
641
+ text: selectedMessage.text,
642
+ direction: selectedMessage.direction,
643
+ timestamp: selectedMessage.timestamp
599
644
  });
645
+
646
+ if (detailAction === 'reply') {
647
+ await showMessageReplyView(ctx, {
648
+ selectedMessage: {
649
+ messageId: selectedMessage.messageId,
650
+ senderNumber: selectedMessage.senderNumber,
651
+ senderName,
652
+ text: selectedMessage.text,
653
+ direction: selectedMessage.direction,
654
+ timestamp: selectedMessage.timestamp
655
+ },
656
+ whatsappService: this.whatsappService,
657
+ recentsService: this.recentsService
658
+ });
659
+ }
600
660
  }
601
661
  }
602
662
 
@@ -611,6 +671,41 @@ export class MenuHandler {
611
671
  return options.find(option => option.label === choice)?.message;
612
672
  }
613
673
 
674
+ private getRecentsGroupKey(conversation: RecentConversationSummary): string {
675
+ if (conversation.lastMessageDirection === 'outgoing') {
676
+ return `outgoing::${conversation.senderNumber}`;
677
+ }
678
+ const d = new Date(conversation.lastMessageTime);
679
+ const minuteKey = `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}-${d.getHours()}-${d.getMinutes()}`;
680
+ return `${conversation.lastMessagePreview}::${minuteKey}`;
681
+ }
682
+
683
+ private groupRecentConversations(conversations: RecentConversationSummary[]): GroupedRecentEntry[] {
684
+ const groups = new Map<string, RecentConversationSummary[]>();
685
+ for (const conversation of conversations) {
686
+ const key = this.getRecentsGroupKey(conversation);
687
+ const existing = groups.get(key) ?? [];
688
+ existing.push(conversation);
689
+ groups.set(key, existing);
690
+ }
691
+ return Array.from(groups.values()).map(members => ({
692
+ conversations: members,
693
+ sharedPreview: members[0].lastMessagePreview,
694
+ sharedTime: members[0].lastMessageTime
695
+ }));
696
+ }
697
+
698
+ private formatGroupedRecentOption(entry: GroupedRecentEntry): string {
699
+ if (entry.conversations.length === 1) {
700
+ return this.formatRecentConversationOption(entry.conversations[0]);
701
+ }
702
+ const time = this.formatDateTime(entry.sharedTime);
703
+ const identifiers = entry.conversations
704
+ .map(c => c.senderName ? `[${c.senderNumber}] ${c.senderName}` : `(${c.senderNumber})`)
705
+ .join(' ');
706
+ return `${identifiers} • ${time} • ${entry.sharedPreview}`;
707
+ }
708
+
614
709
  private formatRecentConversationOption(conversation: RecentConversationSummary): string {
615
710
  const displayName = this.getConversationDisplayName(conversation);
616
711
  const time = this.formatDateTime(conversation.lastMessageTime);
@@ -618,7 +713,8 @@ export class MenuHandler {
618
713
  }
619
714
 
620
715
  private formatAllowedContactOption(contact: Contact): string {
621
- return contact.name ? `${contact.name} (${contact.number})` : contact.number;
716
+ const base = contact.name ? `${contact.name} [${contact.number}]` : contact.number;
717
+ return contact.sendNumber ? `${base} (${contact.sendNumber})` : base;
622
718
  }
623
719
 
624
720
  private formatAllowedGroupOption(group: Contact): string {