whatsapp-pi 1.0.40 → 1.0.42

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "whatsapp-pi",
3
- "version": "1.0.40",
3
+ "version": "1.0.42",
4
4
  "type": "module",
5
5
  "description": "WhatsApp integration extension for Pi",
6
6
  "main": "whatsapp-pi.ts",
@@ -73,6 +73,28 @@ export interface RecentConversationSummary {
73
73
  isAllowed: boolean;
74
74
  }
75
75
 
76
+ export interface SelectedMessageContext {
77
+ messageId: string;
78
+ senderNumber: string;
79
+ senderName?: string;
80
+ text: string;
81
+ direction: MessageDirection;
82
+ timestamp: number;
83
+ }
84
+
85
+ export interface ReplyDraft {
86
+ text: string;
87
+ targetMessageId: string;
88
+ targetConversation: string;
89
+ }
90
+
91
+ export interface ReplySendResult {
92
+ success: boolean;
93
+ messageId?: string;
94
+ error?: string;
95
+ attempts: number;
96
+ }
97
+
76
98
  export interface RecentsStore {
77
99
  conversations: RecentConversationSummary[];
78
100
  messagesBySender: Record<string, RecentConversationMessage[]>;
@@ -39,7 +39,7 @@ export class MessageSender {
39
39
  public async send(request: MessageRequest): Promise<MessageResult> {
40
40
  const maxRetries = request.options?.maxRetries ?? 3;
41
41
  let attempts = 0;
42
- let lastError: any = null;
42
+ let lastError: unknown = null;
43
43
 
44
44
  while (attempts < maxRetries) {
45
45
  attempts++;
@@ -61,15 +61,15 @@ export class MessageSender {
61
61
 
62
62
  return {
63
63
  success: true,
64
- messageId: response.key.id,
64
+ messageId: response?.key?.id,
65
65
  attempts
66
66
  };
67
- } catch (error: any) {
67
+ } catch (error: unknown) {
68
68
  lastError = error;
69
- console.error(`[MessageSender] Attempt ${attempts} failed for ${request.recipientJid}: ${error.message}`);
69
+ console.error(`[MessageSender] Attempt ${attempts} failed for ${request.recipientJid}: ${error instanceof Error ? error.message : String(error)}`);
70
70
 
71
71
  // Specific handling for non-retryable errors
72
- if (error.code === 'TIMEOUT') {
72
+ if (error instanceof WhatsAppError && error.code === 'TIMEOUT') {
73
73
  break;
74
74
  }
75
75
 
@@ -86,7 +86,7 @@ export class MessageSender {
86
86
 
87
87
  return {
88
88
  success: false,
89
- error: lastError?.message || 'Unknown error',
89
+ error: lastError instanceof Error ? lastError.message : 'Unknown error',
90
90
  attempts
91
91
  };
92
92
  }
@@ -93,24 +93,38 @@ export class RecentsService {
93
93
  const summaries = new Map<string, RecentConversationSummary>();
94
94
 
95
95
  for (const [senderNumber, messages] of Object.entries(this.store.messagesBySender)) {
96
- if (messages.length === 0) continue;
97
- const lastMessage = messages[messages.length - 1];
96
+ const latestMessage = this.getLatestConversationMessage(messages);
97
+ if (!latestMessage) continue;
98
+
98
99
  summaries.set(senderNumber, {
99
100
  senderNumber,
100
101
  senderName: previousNames.get(senderNumber),
101
- lastMessagePreview: this.buildPreview(lastMessage.text),
102
- lastMessageTime: lastMessage.timestamp,
103
- lastMessageDirection: lastMessage.direction,
102
+ lastMessagePreview: this.buildPreview(latestMessage.text),
103
+ lastMessageTime: latestMessage.timestamp,
104
+ lastMessageDirection: latestMessage.direction,
104
105
  messageCount: messages.length,
105
106
  isAllowed: this.sessionManager.isAllowed(senderNumber)
106
107
  });
107
108
  }
108
109
 
109
- this.store.conversations = Array.from(summaries.values())
110
- .sort((left, right) => right.lastMessageTime - left.lastMessageTime)
110
+ this.store.conversations = this.sortConversationsByLatestMessage(Array.from(summaries.values()))
111
111
  .slice(0, 20);
112
112
  }
113
113
 
114
+ private getLatestConversationMessage(messages: RecentConversationMessage[]): RecentConversationMessage | undefined {
115
+ return messages[messages.length - 1];
116
+ }
117
+
118
+ private sortConversationsByLatestMessage(conversations: RecentConversationSummary[]): RecentConversationSummary[] {
119
+ return [...conversations].sort((left, right) => {
120
+ if (right.lastMessageTime !== left.lastMessageTime) {
121
+ return right.lastMessageTime - left.lastMessageTime;
122
+ }
123
+
124
+ return left.senderNumber.localeCompare(right.senderNumber);
125
+ });
126
+ }
127
+
114
128
  private buildPreview(text: string): string {
115
129
  const normalized = text.trim().replace(/\s+/g, ' ');
116
130
  if (normalized.length <= 80) return normalized;
@@ -172,12 +186,10 @@ export class RecentsService {
172
186
  isAllowed: this.sessionManager.isAllowed(senderNumber)
173
187
  };
174
188
 
175
- this.store.conversations = [
189
+ this.store.conversations = this.sortConversationsByLatestMessage([
176
190
  summary,
177
191
  ...this.store.conversations.filter(item => item.senderNumber !== senderNumber)
178
- ]
179
- .sort((left, right) => right.lastMessageTime - left.lastMessageTime)
180
- .slice(0, 20);
192
+ ]).slice(0, 20);
181
193
 
182
194
  await this.persistStore();
183
195
  }
@@ -5,6 +5,10 @@ export class WhatsAppPiLogger {
5
5
  this.verbose = verbose;
6
6
  }
7
7
 
8
+ info(message: string, ...args: unknown[]) {
9
+ console.log(message, ...args);
10
+ }
11
+
8
12
  log(message: string, ...args: unknown[]) {
9
13
  if (this.verbose) {
10
14
  console.log(message, ...args);
@@ -1,14 +1,12 @@
1
- import {
1
+ import {
2
2
  makeWASocket,
3
- DisconnectReason,
4
- useMultiFileAuthState,
3
+ DisconnectReason,
5
4
  fetchLatestBaileysVersion,
6
5
  makeCacheableSignalKeyStore
7
6
  } from '@whiskeysockets/baileys';
8
7
  import P from 'pino';
9
- import { Boom } from '@hapi/boom';
10
8
  import { SessionManager } from './session.manager.js';
11
- import { IncomingMessage, WhatsAppSession, SessionStatus } from '../models/whatsapp.types.js';
9
+ import { IncomingMessage, SessionStatus } from '../models/whatsapp.types.js';
12
10
  import { MessageSender } from './message.sender.js';
13
11
  import { installBaileysConsoleFilter } from './baileys-console-filter.js';
14
12
 
@@ -16,8 +14,65 @@ export interface WhatsAppStartOptions {
16
14
  allowPairingOnAuthFailure?: boolean;
17
15
  }
18
16
 
17
+ interface DisconnectPayload {
18
+ error?: unknown;
19
+ }
20
+
21
+ interface ConnectionUpdateEvent {
22
+ connection?: 'close' | 'open' | string;
23
+ lastDisconnect?: DisconnectPayload;
24
+ qr?: string;
25
+ }
26
+
27
+ interface IncomingMessageKey {
28
+ id?: string;
29
+ remoteJid?: string;
30
+ fromMe?: boolean;
31
+ }
32
+
33
+ interface IncomingMessageContent {
34
+ conversation?: string;
35
+ extendedTextMessage?: { text?: string };
36
+ }
37
+
38
+ interface IncomingMessageLike {
39
+ key: IncomingMessageKey;
40
+ message?: IncomingMessageContent;
41
+ pushName?: string;
42
+ messageTimestamp?: number | string;
43
+ }
44
+
45
+ interface MessagesUpsertEvent {
46
+ messages?: IncomingMessageLike[];
47
+ }
48
+
49
+ interface WhatsAppSocketLike {
50
+ ev: {
51
+ on(event: 'connection.update', handler: (update: ConnectionUpdateEvent) => void | Promise<void>): void;
52
+ on(event: 'creds.update', handler: () => void | Promise<void>): void;
53
+ on(event: 'messages.upsert', handler: (payload: MessagesUpsertEvent) => void | Promise<void>): void;
54
+ removeAllListeners(event: 'connection.update' | 'creds.update' | 'messages.upsert'): void;
55
+ };
56
+ end(reason?: unknown): void;
57
+ logout(): Promise<void>;
58
+ sendMessage(jid: string, content: { text: string }): Promise<{ key?: { id?: string } } | undefined>;
59
+ sendPresenceUpdate(presence: 'composing' | 'recording' | 'paused', jid: string): Promise<void>;
60
+ readMessages(messages: Array<{ remoteJid: string; id: string; fromMe: boolean }>): Promise<void>;
61
+ }
62
+
63
+ interface LastDisconnectLike {
64
+ error?: unknown;
65
+ }
66
+
67
+ interface BoomLikeError {
68
+ output?: {
69
+ statusCode?: number;
70
+ };
71
+ message?: string;
72
+ }
73
+
19
74
  export class WhatsAppService {
20
- private socket: any;
75
+ private socket?: WhatsAppSocketLike;
21
76
  private sessionManager: SessionManager;
22
77
  private messageSender: MessageSender;
23
78
  private isReconnecting = false;
@@ -25,6 +80,11 @@ export class WhatsAppService {
25
80
  private onIncomingMessageRecorded?: (message: IncomingMessage) => void | Promise<void>;
26
81
  private saveCreds?: () => Promise<void>;
27
82
  private restoreBaileysConsoleFilter?: () => void;
83
+ private reconnectTimeout?: ReturnType<typeof setTimeout>;
84
+ private onQRCode?: (qr: string) => void;
85
+ private onMessage?: (m: unknown) => void;
86
+ private onStatusUpdate?: (status: string) => void;
87
+ private lastRemoteJid: string | null = null;
28
88
 
29
89
  constructor(sessionManager: SessionManager) {
30
90
  this.sessionManager = sessionManager;
@@ -48,7 +108,7 @@ export class WhatsAppService {
48
108
  this.onIncomingMessageRecorded = callback;
49
109
  }
50
110
 
51
- public getSocket(): any {
111
+ public getSocket(): WhatsAppSocketLike | undefined {
52
112
  return this.socket;
53
113
  }
54
114
 
@@ -76,196 +136,311 @@ export class WhatsAppService {
76
136
  return value;
77
137
  }
78
138
 
79
- async start(options: WhatsAppStartOptions = {}) {
80
- const allowPairingOnAuthFailure = options.allowPairingOnAuthFailure ?? true;
81
- if (this.isReconnecting) return;
82
- this.onStatusUpdate?.('| WhatsApp: Connecting...');
139
+ private normalizeRecipientJid(jid: string): string {
140
+ if (jid.includes('@')) return jid;
141
+ const digits = jid.startsWith('+') ? jid.slice(1) : jid;
142
+ return `${digits}@s.whatsapp.net`;
143
+ }
144
+
145
+ private getDisconnectStatusCode(error: unknown): number | undefined {
146
+ if (!error || typeof error !== 'object') {
147
+ return undefined;
148
+ }
83
149
 
150
+ const candidate = error as BoomLikeError;
151
+ return candidate.output?.statusCode;
152
+ }
153
+
154
+ private getErrorMessage(error: unknown): string {
155
+ if (error instanceof Error) {
156
+ return error.message;
157
+ }
158
+
159
+ if (typeof error === 'object' && error !== null && 'message' in error) {
160
+ const candidate = error as { message?: unknown };
161
+ return typeof candidate.message === 'string' ? candidate.message : '';
162
+ }
163
+
164
+ return '';
165
+ }
166
+
167
+ private clearReconnectTimeout() {
168
+ if (this.reconnectTimeout) {
169
+ clearTimeout(this.reconnectTimeout);
170
+ this.reconnectTimeout = undefined;
171
+ }
172
+ }
173
+
174
+ private cleanupSocket() {
175
+ this.clearReconnectTimeout();
176
+
177
+ if (!this.socket) {
178
+ return;
179
+ }
180
+
181
+ this.restoreBaileysConsoleFilter?.();
182
+ this.restoreBaileysConsoleFilter = undefined;
183
+ this.socket.ev.removeAllListeners('connection.update');
184
+ this.socket.ev.removeAllListeners('creds.update');
185
+ this.socket.ev.removeAllListeners('messages.upsert');
186
+
187
+ try {
188
+ this.socket.end(undefined);
189
+ } catch {
190
+ // Best-effort cleanup
191
+ }
192
+
193
+ this.socket = undefined;
194
+ }
195
+
196
+ private setSocket(socket: WhatsAppSocketLike) {
197
+ this.socket = socket;
198
+ }
199
+
200
+ private registerSocketListeners(socket: WhatsAppSocketLike, options: WhatsAppStartOptions, saveCreds: () => Promise<void>) {
201
+ socket.ev.on('creds.update', async () => {
202
+ await saveCreds();
203
+ await this.sessionManager.markAuthStateAvailable();
204
+ });
205
+
206
+ socket.ev.on('connection.update', async (update) => {
207
+ await this.handleConnectionUpdate(update, options);
208
+ });
209
+
210
+ socket.ev.on('messages.upsert', (payload) => {
211
+ void this.handleIncomingMessages(payload);
212
+ });
213
+ }
214
+
215
+ private async createSocket(): Promise<WhatsAppSocketLike> {
84
216
  const { state, saveCreds } = await this.sessionManager.getAuthState();
85
217
  this.saveCreds = saveCreds;
86
218
  const { version } = await fetchLatestBaileysVersion();
87
219
 
88
- // Cleanup existing socket if any
89
- if (this.socket) {
90
- this.restoreBaileysConsoleFilter?.();
91
- this.restoreBaileysConsoleFilter = undefined;
92
- this.socket.ev.removeAllListeners('connection.update');
93
- this.socket.ev.removeAllListeners('creds.update');
94
- this.socket.ev.removeAllListeners('messages.upsert');
95
- try {
96
- this.socket.end(undefined);
97
- } catch (e) {}
98
- }
99
-
100
220
  const logger = P({ level: this.verboseMode ? 'trace' : 'silent' });
101
-
102
- // Suppress Baileys console output during initialization
221
+
222
+ const socket = makeWASocket({
223
+ version,
224
+ printQRInTerminal: false,
225
+ auth: {
226
+ creds: state.creds,
227
+ keys: makeCacheableSignalKeyStore(state.keys, logger)
228
+ },
229
+ syncFullHistory: false,
230
+ logger
231
+ }) as WhatsAppSocketLike;
232
+
233
+ return socket;
234
+ }
235
+
236
+ async start(options: WhatsAppStartOptions = {}) {
237
+ if (this.isReconnecting) return;
238
+ this.onStatusUpdate?.('| WhatsApp: Connecting...');
239
+
240
+ this.cleanupSocket();
241
+
103
242
  const originalConsoleLog = console.log;
104
243
  const originalConsoleWarn = console.warn;
105
244
  const originalConsoleError = console.error;
106
-
245
+ let socketInitialized = false;
246
+
107
247
  if (!this.verboseMode) {
108
248
  console.log = () => {};
109
249
  console.warn = () => {};
110
250
  console.error = () => {};
111
251
  }
112
-
252
+
113
253
  try {
114
- this.socket = makeWASocket({
115
- version,
116
- printQRInTerminal: false,
117
- auth: {
118
- creds: state.creds,
119
- keys: makeCacheableSignalKeyStore(state.keys, logger),
120
- },
121
- syncFullHistory: false,
122
- logger,
123
- });
254
+ const socket = await this.createSocket();
255
+ this.setSocket(socket);
256
+ this.registerSocketListeners(socket, options, this.saveCreds ?? (async () => {}));
257
+ socketInitialized = true;
124
258
  } catch (error) {
125
- // Restore console methods even if socket creation fails
126
259
  if (!this.verboseMode) {
127
260
  console.log = originalConsoleLog;
128
261
  console.warn = originalConsoleWarn;
129
262
  console.error = originalConsoleError;
130
263
  }
131
264
  throw error;
265
+ } finally {
266
+ if (!this.verboseMode) {
267
+ console.log = originalConsoleLog;
268
+ console.warn = originalConsoleWarn;
269
+ console.error = originalConsoleError;
270
+ if (socketInitialized) {
271
+ this.restoreBaileysConsoleFilter = installBaileysConsoleFilter(this.verboseMode);
272
+ }
273
+ }
132
274
  }
275
+ }
133
276
 
134
- this.socket.ev.on('creds.update', async () => {
135
- await saveCreds();
136
- await this.sessionManager.markAuthStateAvailable();
137
- });
277
+ private async handleConnectionUpdate(update: ConnectionUpdateEvent, options: WhatsAppStartOptions) {
278
+ const { connection, lastDisconnect, qr } = update;
279
+ const allowPairingOnAuthFailure = options.allowPairingOnAuthFailure ?? true;
138
280
 
139
- this.socket.ev.on('connection.update', async (update: any) => {
140
- const { connection, lastDisconnect, qr } = update;
141
-
142
- if (qr) {
143
- this.sessionManager.setStatus('pairing');
144
- this.onQRCode?.(qr);
145
- this.onStatusUpdate?.('| WhatsApp: type /whatsapp to connect');
146
- }
281
+ if (qr) {
282
+ await this.handlePairingQr(qr);
283
+ }
147
284
 
148
- if (connection === 'close') {
149
- const statusCode = (lastDisconnect?.error as Boom)?.output?.statusCode;
150
- const errorMessage = lastDisconnect?.error?.message || '';
151
- const shouldReconnect = statusCode !== DisconnectReason.loggedOut;
152
- const isBadMac = errorMessage.includes('Bad MAC');
153
- const isAuthRejected =
154
- errorMessage.includes('bad-request') ||
155
- statusCode === 400 ||
156
- statusCode === 401 ||
157
- statusCode === DisconnectReason.loggedOut ||
158
- statusCode === DisconnectReason.badSession;
159
- const shouldTreatAsLoggedOut = isBadMac || isAuthRejected;
285
+ if (connection === 'close') {
286
+ await this.handleConnectionClosed(lastDisconnect, allowPairingOnAuthFailure, options);
287
+ return;
288
+ }
160
289
 
161
- if (this.verboseMode) {
162
- console.error(`Connection closed [${statusCode}]. Reconnecting: ${shouldReconnect}`);
163
- }
290
+ if (connection === 'open') {
291
+ await this.handleConnectionOpen();
292
+ }
293
+ }
164
294
 
165
- if (shouldTreatAsLoggedOut) {
166
- if (isAuthRejected && !isBadMac && allowPairingOnAuthFailure) {
167
- if (this.verboseMode) {
168
- console.error(`Session rejected [${statusCode}] - clearing auth state and starting pairing`);
169
- }
170
- await this.sessionManager.deleteAuthState();
171
- this.socket.ev.removeAllListeners('connection.update');
172
- this.socket.ev.removeAllListeners('creds.update');
173
- this.socket.ev.removeAllListeners('messages.upsert');
174
- try {
175
- this.socket.end(undefined);
176
- } catch (e) {}
177
- this.socket = undefined;
178
- await this.start({ allowPairingOnAuthFailure: false });
179
- return;
180
- }
181
-
182
- if (this.verboseMode) {
183
- console.error(`Session invalid or logged out [${statusCode}] - preserving auth state and requiring re-auth`);
184
- }
185
- if (isBadMac) {
186
- if (this.verboseMode) {
187
- console.error('[WhatsApp-Pi] Bad MAC error detected. Your session keys are corrupted.');
188
- console.error('[WhatsApp-Pi] Run /whatsapp-logout to clear auth state, then reconnect with /whatsapp-connect');
189
- }
190
- this.onStatusUpdate?.('| WhatsApp: Session Error (Bad MAC)');
191
- }
192
- this.sessionManager.setStatus('logged-out');
193
- if (!isBadMac) {
194
- this.onStatusUpdate?.('| WhatsApp: Logged out');
195
- }
196
- return;
197
- }
295
+ private async handlePairingQr(qr: string) {
296
+ this.sessionManager.setStatus('pairing');
297
+ this.onQRCode?.(qr);
298
+ this.onStatusUpdate?.('| WhatsApp: type /whatsapp to connect');
299
+ }
198
300
 
199
- if (statusCode === DisconnectReason.connectionReplaced) {
200
- if (this.verboseMode) {
201
- console.error('Connection replaced - another instance connected');
202
- }
203
- this.onStatusUpdate?.('| WhatsApp: Conflict (Another Instance)');
204
- return;
205
- }
206
-
207
- if (shouldReconnect && !this.isReconnecting) {
208
- this.isReconnecting = true;
209
- this.onStatusUpdate?.('| WhatsApp: Reconnecting...');
210
- setTimeout(() => {
211
- this.isReconnecting = false;
212
- this.start(options);
213
- }, 3000);
214
- } else if (!shouldReconnect) {
215
- this.sessionManager.setStatus('logged-out');
216
- this.onStatusUpdate?.('| WhatsApp: Disconnected');
217
- }
218
- } else if (connection === 'open') {
301
+ private async handleConnectionOpen() {
302
+ if (this.verboseMode) {
303
+ console.log('WhatsApp connection successfully opened');
304
+ }
305
+
306
+ this.isReconnecting = false;
307
+ this.clearReconnectTimeout();
308
+ await this.saveCreds?.();
309
+ await this.sessionManager.markAuthStateAvailable();
310
+ this.sessionManager.setStatus('connected');
311
+ this.onStatusUpdate?.('| WhatsApp: Connected');
312
+ }
313
+
314
+ private isBadMacError(errorMessage: string): boolean {
315
+ return errorMessage.includes('Bad MAC');
316
+ }
317
+
318
+ private isAuthRejected(statusCode: number | undefined, errorMessage: string): boolean {
319
+ return errorMessage.includes('bad-request')
320
+ || statusCode === 400
321
+ || statusCode === 401
322
+ || statusCode === DisconnectReason.loggedOut
323
+ || statusCode === DisconnectReason.badSession;
324
+ }
325
+
326
+ private async handleConnectionClosed(
327
+ lastDisconnect: LastDisconnectLike | undefined,
328
+ allowPairingOnAuthFailure: boolean,
329
+ options: WhatsAppStartOptions
330
+ ) {
331
+ const statusCode = this.getDisconnectStatusCode(lastDisconnect?.error);
332
+ const errorMessage = this.getErrorMessage(lastDisconnect?.error);
333
+ const shouldReconnect = statusCode !== DisconnectReason.loggedOut;
334
+ const isBadMac = this.isBadMacError(errorMessage);
335
+ const isAuthRejected = this.isAuthRejected(statusCode, errorMessage);
336
+ const shouldTreatAsLoggedOut = isBadMac || isAuthRejected;
337
+
338
+ if (this.verboseMode) {
339
+ console.error(`Connection closed [${statusCode}]. Reconnecting: ${shouldReconnect}`);
340
+ }
341
+
342
+ if (shouldTreatAsLoggedOut) {
343
+ if (isAuthRejected && !isBadMac && allowPairingOnAuthFailure) {
219
344
  if (this.verboseMode) {
220
- console.log('WhatsApp connection successfully opened');
345
+ console.error(`Session rejected [${statusCode}] - clearing auth state and starting pairing`);
221
346
  }
347
+ await this.sessionManager.deleteAuthState();
348
+ this.cleanupSocket();
349
+ this.socket = undefined;
222
350
  this.isReconnecting = false;
223
- await this.saveCreds?.();
224
- await this.sessionManager.markAuthStateAvailable();
225
- this.sessionManager.setStatus('connected');
226
- this.onStatusUpdate?.('| WhatsApp: Connected');
351
+ await this.start({ allowPairingOnAuthFailure: false });
352
+ return;
227
353
  }
228
- });
229
354
 
230
- this.socket.ev.on('messages.upsert', (m: any) => this.handleIncomingMessages(m));
355
+ if (this.verboseMode) {
356
+ console.error(`Session invalid or logged out [${statusCode}] - preserving auth state and requiring re-auth`);
357
+ }
358
+ if (isBadMac) {
359
+ if (this.verboseMode) {
360
+ console.error('[WhatsApp-Pi] Bad MAC error detected. Your session keys are corrupted.');
361
+ console.error('[WhatsApp-Pi] Run /whatsapp-logout to clear auth state, then reconnect with /whatsapp-connect');
362
+ }
363
+ this.onStatusUpdate?.('| WhatsApp: Session Error (Bad MAC)');
364
+ }
365
+ this.sessionManager.setStatus('logged-out');
366
+ if (!isBadMac) {
367
+ this.onStatusUpdate?.('| WhatsApp: Logged out');
368
+ }
369
+ return;
370
+ }
231
371
 
232
- // Restore console methods after socket creation
233
- if (!this.verboseMode) {
234
- console.log = originalConsoleLog;
235
- console.warn = originalConsoleWarn;
236
- console.error = originalConsoleError;
237
- this.restoreBaileysConsoleFilter = installBaileysConsoleFilter(this.verboseMode);
372
+ if (statusCode === DisconnectReason.connectionReplaced) {
373
+ if (this.verboseMode) {
374
+ console.error('Connection replaced - another instance connected');
375
+ }
376
+ this.onStatusUpdate?.('| WhatsApp: Conflict (Another Instance)');
377
+ return;
378
+ }
379
+
380
+ if (shouldReconnect && !this.isReconnecting) {
381
+ this.isReconnecting = true;
382
+ this.onStatusUpdate?.('| WhatsApp: Reconnecting...');
383
+ this.clearReconnectTimeout();
384
+ this.reconnectTimeout = setTimeout(() => {
385
+ this.isReconnecting = false;
386
+ void this.start(options);
387
+ }, 3000);
388
+ } else if (!shouldReconnect) {
389
+ this.sessionManager.setStatus('logged-out');
390
+ this.onStatusUpdate?.('| WhatsApp: Disconnected');
238
391
  }
239
392
  }
240
393
 
241
- public async handleIncomingMessages(m: any) {
242
- if (this.sessionManager.getStatus() !== 'connected') return;
243
- const msg = m.messages[0];
394
+ private extractText(message: IncomingMessageContent | undefined): string {
395
+ return message?.conversation || message?.extendedTextMessage?.text || '';
396
+ }
244
397
 
245
- // msg.key.fromMe is always allowed
246
- if (!msg || !msg.key.remoteJid) return;
398
+ private isPiGeneratedMessage(text: string): boolean {
399
+ return text.endsWith('π');
400
+ }
247
401
 
248
- // Ignore messages sent by Pi (marked with π)
249
- const text = msg.message?.conversation || msg.message?.extendedTextMessage?.text || "";
250
- if (text.endsWith('π')) return;
402
+ private getIncomingTimestamp(timestamp: number | string | undefined): number {
403
+ if (typeof timestamp === 'number') {
404
+ return timestamp;
405
+ }
251
406
 
252
- const remoteJid = msg.key.remoteJid;
253
- if (remoteJid.endsWith('@g.us')) return;
407
+ if (typeof timestamp === 'string') {
408
+ const parsed = Number(timestamp);
409
+ return Number.isFinite(parsed) ? parsed : Date.now();
410
+ }
254
411
 
255
- const senderJid = this.normalizeContactNumber(remoteJid.split('@')[0]);
412
+ return Date.now();
413
+ }
256
414
 
415
+ private async recordIncomingMessage(message: IncomingMessageLike, remoteJid: string, text: string) {
257
416
  void Promise.resolve(this.onIncomingMessageRecorded?.({
258
- id: msg.key.id,
417
+ id: message.key.id ?? remoteJid,
259
418
  remoteJid,
260
- pushName: msg.pushName || undefined,
419
+ pushName: message.pushName || undefined,
261
420
  text,
262
- timestamp: typeof msg.messageTimestamp === 'number' ? Number(msg.messageTimestamp) : Date.now()
421
+ timestamp: this.getIncomingTimestamp(message.messageTimestamp)
263
422
  })).catch(error => {
264
423
  if (this.verboseMode) {
265
424
  console.error('Failed to record recent message:', error);
266
425
  }
267
426
  });
268
-
427
+ }
428
+
429
+ public async handleIncomingMessages(payload: MessagesUpsertEvent) {
430
+ if (this.sessionManager.getStatus() !== 'connected') return;
431
+
432
+ const message = payload.messages?.[0];
433
+ if (!message || !message.key.remoteJid) return;
434
+
435
+ const text = this.extractText(message.message);
436
+ if (this.isPiGeneratedMessage(text)) return;
437
+
438
+ const remoteJid = message.key.remoteJid;
439
+ if (remoteJid.endsWith('@g.us')) return;
440
+
441
+ const senderJid = this.normalizeContactNumber(remoteJid.split('@')[0]);
442
+ void this.recordIncomingMessage(message, remoteJid, text);
443
+
269
444
  if (this.sessionManager.isBlocked(senderJid)) {
270
445
  if (this.isVerbose()) {
271
446
  console.log(`Ignoring message from ${senderJid} (explicitly blocked)`);
@@ -277,26 +452,20 @@ export class WhatsAppService {
277
452
  if (this.isVerbose()) {
278
453
  console.log(`Ignoring message from ${senderJid} (not in allow list)`);
279
454
  }
280
- // Track this number as ignored so user can allow it later
281
- const pushName = msg.pushName || undefined;
455
+ const pushName = message.pushName || undefined;
282
456
  await this.sessionManager.trackIgnoredNumber(senderJid, pushName);
283
457
  return;
284
458
  }
285
459
 
286
- this.lastRemoteJid = msg.key.remoteJid;
287
- this.onMessage?.(m);
460
+ this.lastRemoteJid = remoteJid;
461
+ this.onMessage?.(payload);
288
462
  }
289
463
 
290
- private onQRCode?: (qr: string) => void;
291
- private onMessage?: (m: any) => void;
292
- private onStatusUpdate?: (status: string) => void;
293
- private lastRemoteJid: string | null = null;
294
-
295
464
  setQRCodeCallback(callback: (qr: string) => void) {
296
465
  this.onQRCode = callback;
297
466
  }
298
467
 
299
- setMessageCallback(callback: (m: any) => void) {
468
+ setMessageCallback(callback: (m: unknown) => void) {
300
469
  this.onMessage = callback;
301
470
  }
302
471
 
@@ -308,6 +477,14 @@ export class WhatsAppService {
308
477
  return this.lastRemoteJid;
309
478
  }
310
479
 
480
+ private getActiveSocket(): WhatsAppSocketLike | null {
481
+ if (!this.socket || this.getStatus() !== 'connected') {
482
+ return null;
483
+ }
484
+
485
+ return this.socket;
486
+ }
487
+
311
488
  async sendMessage(jid: string, text: string) {
312
489
  // Ensure we show the typing indicator before sending
313
490
  await this.sendPresence(jid, 'composing');
@@ -323,20 +500,15 @@ export class WhatsAppService {
323
500
  if (!result.success) {
324
501
  console.error(`Failed to send message to ${jid}: ${result.error}`);
325
502
  }
326
-
327
- return result;
328
- }
329
503
 
330
- private normalizeRecipientJid(jid: string): string {
331
- if (jid.includes('@')) return jid;
332
- const digits = jid.startsWith('+') ? jid.slice(1) : jid;
333
- return `${digits}@s.whatsapp.net`;
504
+ return result;
334
505
  }
335
506
 
336
507
  async sendMenuMessage(jid: string, text: string) {
337
508
  const normalizedJid = this.normalizeRecipientJid(jid);
509
+ const socket = this.getActiveSocket();
338
510
 
339
- if (!this.socket || this.getStatus() !== 'connected') {
511
+ if (!socket) {
340
512
  return {
341
513
  success: false,
342
514
  error: 'WhatsApp is not connected',
@@ -346,7 +518,7 @@ export class WhatsAppService {
346
518
 
347
519
  try {
348
520
  await this.sendPresence(normalizedJid, 'composing');
349
- const response = await this.socket.sendMessage(normalizedJid, { text });
521
+ const response = await socket.sendMessage(normalizedJid, { text });
350
522
  await this.sendPresence(normalizedJid, 'paused');
351
523
 
352
524
  return {
@@ -354,21 +526,22 @@ export class WhatsAppService {
354
526
  messageId: response?.key?.id,
355
527
  attempts: 1
356
528
  };
357
- } catch (error: any) {
529
+ } catch (error: unknown) {
358
530
  await this.sendPresence(normalizedJid, 'paused');
359
531
  console.error(`Failed to send menu message to ${normalizedJid}:`, error);
360
532
  return {
361
533
  success: false,
362
- error: error?.message || 'Unknown error',
534
+ error: error instanceof Error ? error.message : 'Unknown error',
363
535
  attempts: 1
364
536
  };
365
537
  }
366
538
  }
367
539
 
368
540
  async sendPresence(jid: string, presence: 'composing' | 'recording' | 'paused') {
369
- if (!this.socket || this.getStatus() !== 'connected') return;
541
+ const socket = this.getActiveSocket();
542
+ if (!socket) return;
370
543
  try {
371
- await this.socket.sendPresenceUpdate(presence, jid);
544
+ await socket.sendPresenceUpdate(presence, jid);
372
545
  } catch (error) {
373
546
  if (this.verboseMode) {
374
547
  console.error(`Failed to send presence update to ${jid}:`, error);
@@ -377,9 +550,10 @@ export class WhatsAppService {
377
550
  }
378
551
 
379
552
  async markRead(jid: string, messageId: string, fromMe: boolean = false) {
380
- if (!this.socket || this.getStatus() !== 'connected') return;
553
+ const socket = this.getActiveSocket();
554
+ if (!socket) return;
381
555
  try {
382
- await this.socket.readMessages([{ remoteJid: jid, id: messageId, fromMe }]);
556
+ await socket.readMessages([{ remoteJid: jid, id: messageId, fromMe }]);
383
557
  } catch (error) {
384
558
  if (this.verboseMode) {
385
559
  console.error(`Failed to mark message as read:`, error);
@@ -401,18 +575,8 @@ export class WhatsAppService {
401
575
  }
402
576
  }
403
577
 
404
- if (this.socket) {
405
- this.restoreBaileysConsoleFilter?.();
406
- this.restoreBaileysConsoleFilter = undefined;
407
- this.socket.ev.removeAllListeners('connection.update');
408
- this.socket.ev.removeAllListeners('creds.update');
409
- this.socket.ev.removeAllListeners('messages.upsert');
410
- try {
411
- this.socket.end(undefined);
412
- } catch (e) {}
413
- this.socket = undefined;
414
- this.isReconnecting = false;
415
- }
578
+ this.cleanupSocket();
579
+ this.isReconnecting = false;
416
580
  await this.sessionManager.setStatus('disconnected');
417
581
  this.onStatusUpdate?.('| WhatsApp: Disconnected');
418
582
  }
@@ -1,10 +1,17 @@
1
1
  import { WhatsAppService } from '../services/whatsapp.service.js';
2
2
  import { SessionManager, type Contact } from '../services/session.manager.js';
3
- import { validatePhoneNumber, type RecentConversationSummary } from '../models/whatsapp.types.js';
3
+ import { validatePhoneNumber, type RecentConversationMessage, type RecentConversationSummary } from '../models/whatsapp.types.js';
4
4
  import { RecentsService } from '../services/recents.service.js';
5
+ import { showMessageDetailView } from './message-detail.view.js';
6
+ import { showMessageReplyView } from './message-reply.view.js';
5
7
  import * as qrcode from 'qrcode-terminal';
6
8
  import type { ExtensionCommandContext } from '@mariozechner/pi-coding-agent';
7
9
 
10
+ interface HistoryOptionEntry {
11
+ label: string;
12
+ message: RecentConversationMessage;
13
+ }
14
+
8
15
  export class MenuHandler {
9
16
  private readonly printedAllowedNumbers: string[] = [];
10
17
 
@@ -109,7 +116,7 @@ export class MenuHandler {
109
116
 
110
117
  private async manageAllowedContact(ctx: ExtensionCommandContext, contact: Contact) {
111
118
  const displayName = this.formatAllowedContactOption(contact);
112
- const options = ['Send Message', 'Print Number', 'History'];
119
+ const options = ['History', 'Send Message', 'Print Number'];
113
120
  if (contact.name) {
114
121
  options.push('Remove Alias');
115
122
  } else {
@@ -175,11 +182,18 @@ export class MenuHandler {
175
182
 
176
183
  private printAllowedNumber(ctx: ExtensionCommandContext, number: string) {
177
184
  this.printedAllowedNumbers.push(number);
185
+ const output = this.printedAllowedNumbers
186
+ .map((entry) => ` • ${entry}`)
187
+ .join('\n');
188
+ console.log([
189
+ '[WhatsApp-Pi] Allowed numbers',
190
+ output
191
+ ].join('\n'));
178
192
  ctx.ui.notify(this.printedAllowedNumbers.join('\n'), 'info');
179
193
  }
180
194
 
181
195
  private async manageBlockList(ctx: ExtensionCommandContext) {
182
- const list = [...this.sessionManager.getIgnoredNumbers()].reverse();
196
+ const list = [...this.sessionManager.getBlockList()].reverse();
183
197
 
184
198
  if (list.length === 0) {
185
199
  ctx.ui.notify('No blocked numbers', 'info');
@@ -203,16 +217,14 @@ export class MenuHandler {
203
217
  if (action === 'Allow') {
204
218
  const ok = await ctx.ui.confirm('Allow', `Move ${number} to Allowed Numbers?`);
205
219
  if (ok) {
206
- const list = this.sessionManager.getIgnoredNumbers();
207
- const contact = list.find(c => c.number === number);
208
- await this.sessionManager.addNumber(number, contact?.name);
220
+ await this.sessionManager.unblockAndAllow(number);
209
221
  ctx.ui.notify(`${number} moved to Allowed List`, 'info');
210
222
  }
211
223
  await this.manageBlockList(ctx);
212
224
  } else if (action === 'Delete') {
213
225
  const ok = await ctx.ui.confirm('Delete', `Remove ${number} from Block List?`);
214
226
  if (ok) {
215
- await this.sessionManager.removeIgnoredNumber(number);
227
+ await this.sessionManager.unblockNumber(number);
216
228
  ctx.ui.notify(`${number} removed from Block List`, 'info');
217
229
  }
218
230
  await this.manageBlockList(ctx);
@@ -256,13 +268,13 @@ export class MenuHandler {
256
268
  private async manageRecentConversation(ctx: ExtensionCommandContext, conversation: RecentConversationSummary) {
257
269
  const displayName = this.getConversationDisplayName(conversation);
258
270
  const allowedContact = this.sessionManager.getAllowedContact(conversation.senderNumber);
259
- const options: string[] = [];
271
+ const options: string[] = ['History'];
260
272
 
261
273
  if (!allowedContact) {
262
274
  options.push('Allow Number');
263
275
  }
264
276
 
265
- options.push('History', 'Send Message');
277
+ options.push('Send Message');
266
278
 
267
279
  if (allowedContact?.name) {
268
280
  options.push('Remove Alias');
@@ -364,10 +376,20 @@ export class MenuHandler {
364
376
  }
365
377
 
366
378
  private async showConversationHistory(ctx: ExtensionCommandContext, conversation: RecentConversationSummary) {
367
- await this.showConversationHistoryForNumber(ctx, conversation.senderNumber, this.getConversationDisplayName(conversation));
379
+ await this.showConversationHistoryForNumber(
380
+ ctx,
381
+ conversation.senderNumber,
382
+ this.getConversationDisplayName(conversation),
383
+ conversation.senderName
384
+ );
368
385
  }
369
386
 
370
- private async showConversationHistoryForNumber(ctx: ExtensionCommandContext, senderNumber: string, displayName: string) {
387
+ private async showConversationHistoryForNumber(
388
+ ctx: ExtensionCommandContext,
389
+ senderNumber: string,
390
+ displayName: string,
391
+ senderName?: string
392
+ ) {
371
393
  const history = await this.recentsService.getConversationHistory(senderNumber);
372
394
 
373
395
  if (history.length === 0) {
@@ -375,16 +397,56 @@ export class MenuHandler {
375
397
  return;
376
398
  }
377
399
 
378
- const options = [
379
- ...this.sortHistoryByMostRecent(history)
380
- .map(message => this.formatHistoryOption(message.timestamp, message.direction, message.text)),
400
+ const historyOptions = this.buildHistoryOptions(this.sortHistoryByMostRecent(history));
401
+ const choice = await ctx.ui.select(`History • ${displayName}`, [
402
+ ...historyOptions.map(option => option.label),
381
403
  'Back'
382
- ];
404
+ ]);
383
405
 
384
- const choice = await ctx.ui.select(`History ${displayName}`, options);
385
- if (choice === 'Back' || !choice) {
406
+ if (!choice || choice === 'Back') {
386
407
  return;
387
408
  }
409
+
410
+ const selectedMessage = this.resolveHistorySelection(choice, historyOptions);
411
+ if (!selectedMessage) {
412
+ return;
413
+ }
414
+
415
+ const detailAction = await showMessageDetailView(ctx, {
416
+ title: `Message • ${displayName}`,
417
+ messageId: selectedMessage.messageId,
418
+ senderNumber: selectedMessage.senderNumber,
419
+ senderName,
420
+ text: selectedMessage.text,
421
+ direction: selectedMessage.direction,
422
+ timestamp: selectedMessage.timestamp
423
+ });
424
+
425
+ if (detailAction === 'reply') {
426
+ await showMessageReplyView(ctx, {
427
+ selectedMessage: {
428
+ messageId: selectedMessage.messageId,
429
+ senderNumber: selectedMessage.senderNumber,
430
+ senderName,
431
+ text: selectedMessage.text,
432
+ direction: selectedMessage.direction,
433
+ timestamp: selectedMessage.timestamp
434
+ },
435
+ whatsappService: this.whatsappService,
436
+ recentsService: this.recentsService
437
+ });
438
+ }
439
+ }
440
+
441
+ private buildHistoryOptions(history: RecentConversationMessage[]): HistoryOptionEntry[] {
442
+ return history.map(message => ({
443
+ label: this.formatHistoryOption(message.timestamp, message.direction, message.text),
444
+ message
445
+ }));
446
+ }
447
+
448
+ private resolveHistorySelection(choice: string, options: HistoryOptionEntry[]): RecentConversationMessage | undefined {
449
+ return options.find(option => option.label === choice)?.message;
388
450
  }
389
451
 
390
452
  private formatRecentConversationOption(conversation: RecentConversationSummary): string {
@@ -0,0 +1,119 @@
1
+ import { matchesKey, truncateToWidth, wrapTextWithAnsi, visibleWidth } from '@mariozechner/pi-tui';
2
+ import type { SelectedMessageContext } from '../models/whatsapp.types.js';
3
+
4
+ export interface MessageDetailViewProps extends SelectedMessageContext {
5
+ title: string;
6
+ onClose: () => void;
7
+ onReply?: () => void | Promise<void>;
8
+ }
9
+
10
+ export class MessageDetailView {
11
+ constructor(private readonly props: MessageDetailViewProps) {}
12
+
13
+ handleInput(data: string): void {
14
+ const normalized = data.toLowerCase();
15
+
16
+ if (normalized === 'r' || matchesKey(data, 'r')) {
17
+ void this.props.onReply?.();
18
+ return;
19
+ }
20
+
21
+ if (
22
+ data === 'enter' ||
23
+ data === 'return' ||
24
+ data === 'escape' ||
25
+ data === 'esc' ||
26
+ matchesKey(data, 'enter') ||
27
+ matchesKey(data, 'escape') ||
28
+ matchesKey(data, 'backspace')
29
+ ) {
30
+ this.props.onClose();
31
+ }
32
+ }
33
+
34
+ render(width: number): string[] {
35
+ const title = this.props.title.trim() || 'Message Details';
36
+ const bodyText = this.props.text.length > 0 ? this.props.text : '[No readable text available]';
37
+
38
+ const availableWidth = Math.max(20, width - 4);
39
+ const rawHeaderLines = [
40
+ `Message ID: ${this.props.messageId}`,
41
+ `From: ${this.formatSender()}`,
42
+ `Direction: ${this.formatDirection()} • Time: ${this.formatTimestamp(this.props.timestamp)}`
43
+ ];
44
+
45
+ const contentWidth = Math.min(
46
+ availableWidth,
47
+ Math.max(
48
+ visibleWidth('Press Enter or Esc to return'),
49
+ ...rawHeaderLines.map(line => visibleWidth(line)),
50
+ ...wrapTextWithAnsi(bodyText, availableWidth).map(line => visibleWidth(line))
51
+ )
52
+ );
53
+
54
+ const wrapWidth = Math.max(1, contentWidth);
55
+ const boxWidth = wrapWidth + 4;
56
+ const padLine = (line: string) => `│ ${truncateToWidth(line, wrapWidth).padEnd(wrapWidth, ' ')} │`;
57
+ const centerLine = (line: string) => {
58
+ const content = truncateToWidth(line, wrapWidth);
59
+ const visible = visibleWidth(content);
60
+ const leftPadding = Math.max(0, Math.floor((wrapWidth - visible) / 2));
61
+ const rightPadding = Math.max(0, wrapWidth - visible - leftPadding);
62
+ return `│ ${' '.repeat(leftPadding)}${content}${' '.repeat(rightPadding)} │`;
63
+ };
64
+ const topBorder = `╭${'─'.repeat(boxWidth - 2)}╮`;
65
+ const separator = `├${'─'.repeat(boxWidth - 2)}┤`;
66
+ const bottomBorder = `╰${'─'.repeat(boxWidth - 2)}╯`;
67
+ const bodyLines = wrapTextWithAnsi(bodyText, wrapWidth).filter(line => line.length > 0 || bodyText.length === 0);
68
+ const exitHint = this.props.onReply
69
+ ? `\x1b[90mPress R to reply • Enter or Esc to return\x1b[39m`
70
+ : `\x1b[90mPress Enter or Esc to return\x1b[39m`;
71
+
72
+ return [
73
+ topBorder,
74
+ ...rawHeaderLines.map(padLine),
75
+ separator,
76
+ ...bodyLines.map(padLine),
77
+ separator,
78
+ centerLine(exitHint),
79
+ bottomBorder
80
+ ];
81
+ }
82
+
83
+ invalidate(): void {}
84
+
85
+ private formatSender(): string {
86
+ return this.props.senderName
87
+ ? `${this.props.senderName} (${this.props.senderNumber})`
88
+ : this.props.senderNumber;
89
+ }
90
+
91
+ private formatDirection(): string {
92
+ return this.props.direction === 'outgoing' ? 'Sent' : 'Received';
93
+ }
94
+
95
+ private formatTimestamp(timestamp: number): string {
96
+ return new Intl.DateTimeFormat(undefined, {
97
+ dateStyle: 'short',
98
+ timeStyle: 'medium'
99
+ }).format(new Date(timestamp));
100
+ }
101
+ }
102
+
103
+ export async function showMessageDetailView(
104
+ ctx: {
105
+ ui: {
106
+ custom: <T>(factory: (_tui: unknown, _theme: unknown, _keybindings: unknown, done: (value: T | undefined) => void) => MessageDetailView, options?: { overlay?: boolean }) => Promise<T | undefined>;
107
+ }
108
+ },
109
+ props: Omit<MessageDetailViewProps, 'onClose'>
110
+ ): Promise<'reply' | undefined> {
111
+ return await ctx.ui.custom<'reply' | undefined>(
112
+ (_tui, _theme, _keybindings, done) => new MessageDetailView({
113
+ ...props,
114
+ onClose: () => done(undefined),
115
+ onReply: () => done('reply')
116
+ }),
117
+ { overlay: true }
118
+ );
119
+ }
@@ -0,0 +1,105 @@
1
+ import type { ReplyDraft, ReplySendResult, SelectedMessageContext } from '../models/whatsapp.types.js';
2
+ import { truncateToWidth } from '@mariozechner/pi-tui';
3
+ import type { WhatsAppService } from '../services/whatsapp.service.js';
4
+ import type { RecentsService } from '../services/recents.service.js';
5
+
6
+ export interface MessageReplyViewProps {
7
+ selectedMessage: SelectedMessageContext;
8
+ whatsappService: WhatsAppService;
9
+ recentsService: RecentsService;
10
+ }
11
+
12
+ interface MessageReplyContextUi {
13
+ editor(title: string, prefilled?: string): Promise<string | undefined>;
14
+ notify(message: string, level: 'info' | 'warning' | 'error'): void;
15
+ setWidget(name: string, widget?: string[] | ((tui: unknown, theme: { fg: (tone: string, text: string) => string }) => { render(width: number): string[]; invalidate(): void }) , options?: { placement?: 'belowEditor' }): void;
16
+ }
17
+
18
+ interface MessageReplyContext {
19
+ ui: MessageReplyContextUi;
20
+ }
21
+
22
+ const buildPreview = (text: string): string => {
23
+ const normalized = text.trim().replace(/\s+/g, ' ');
24
+ if (!normalized) {
25
+ return '[No readable text available]';
26
+ }
27
+
28
+ return normalized.length > 120 ? `${normalized.slice(0, 117)}...` : normalized;
29
+ };
30
+
31
+ const buildReplyWidget = (selectedMessage: SelectedMessageContext): string[] => {
32
+ const sender = selectedMessage.senderName
33
+ ? `${selectedMessage.senderName} (${selectedMessage.senderNumber})`
34
+ : selectedMessage.senderNumber;
35
+
36
+ return [
37
+ `Replying to: ${sender}`,
38
+ `Message ID: ${selectedMessage.messageId}`,
39
+ `Original: ${buildPreview(selectedMessage.text)}`
40
+ ];
41
+ };
42
+
43
+ const buildReplyTitle = (selectedMessage: SelectedMessageContext): string => {
44
+ const sender = selectedMessage.senderName
45
+ ? `${selectedMessage.senderName} (${selectedMessage.senderNumber})`
46
+ : selectedMessage.senderNumber;
47
+
48
+ return truncateToWidth(`Reply to ${sender}`, 120);
49
+ };
50
+
51
+ export async function showMessageReplyView(
52
+ ctx: MessageReplyContext,
53
+ props: MessageReplyViewProps
54
+ ): Promise<void> {
55
+ const widgetName = 'message-reply-context';
56
+ ctx.ui.setWidget(widgetName, buildReplyWidget(props.selectedMessage), { placement: 'belowEditor' });
57
+
58
+ try {
59
+ while (true) {
60
+ const replyText = await ctx.ui.editor(buildReplyTitle(props.selectedMessage));
61
+
62
+ if (replyText === undefined) {
63
+ return;
64
+ }
65
+
66
+ const text = replyText.trim();
67
+ if (!text) {
68
+ ctx.ui.notify('Please enter a message before sending.', 'error');
69
+ continue;
70
+ }
71
+
72
+ const draft: ReplyDraft = {
73
+ text,
74
+ targetMessageId: props.selectedMessage.messageId,
75
+ targetConversation: props.selectedMessage.senderNumber
76
+ };
77
+
78
+ const result: ReplySendResult = await props.whatsappService.sendMenuMessage(
79
+ props.selectedMessage.senderNumber,
80
+ draft.text
81
+ );
82
+
83
+ if (result.success) {
84
+ await props.recentsService.recordMessage({
85
+ messageId: result.messageId ?? `${Date.now()}`,
86
+ senderNumber: props.selectedMessage.senderNumber,
87
+ senderName: props.selectedMessage.senderName,
88
+ text: draft.text,
89
+ direction: 'outgoing',
90
+ timestamp: Date.now()
91
+ });
92
+ ctx.ui.notify(`Sent reply to ${buildPreview(props.selectedMessage.text)}`, 'info');
93
+ } else {
94
+ ctx.ui.notify(
95
+ `Failed to send reply: ${result.error ?? 'Unknown error'}`,
96
+ 'error'
97
+ );
98
+ }
99
+
100
+ return;
101
+ }
102
+ } finally {
103
+ ctx.ui.setWidget(widgetName, undefined);
104
+ }
105
+ }
package/whatsapp-pi.ts CHANGED
@@ -240,6 +240,18 @@ export default function (pi: ExtensionAPI) {
240
240
  };
241
241
  }
242
242
 
243
+ const formattedMessage = params.message
244
+ .split('\n')
245
+ .map((line) => ` ${line}`)
246
+ .join('\n');
247
+
248
+ console.log([
249
+ '[WhatsApp-Pi] Outgoing WhatsApp message',
250
+ ` To: ${params.jid}`,
251
+ ' Message:',
252
+ formattedMessage
253
+ ].join('\n'));
254
+
243
255
  const result = await whatsappService.sendMessage(params.jid, params.message);
244
256
 
245
257
  if (result.success) {
@@ -250,6 +262,19 @@ export default function (pi: ExtensionAPI) {
250
262
  direction: 'outgoing',
251
263
  timestamp: Date.now()
252
264
  });
265
+ console.log([
266
+ '[WhatsApp-Pi] Outgoing WhatsApp message result',
267
+ ` To: ${params.jid}`,
268
+ ' Status: sent',
269
+ ` MessageId: ${result.messageId ?? 'unknown'}`
270
+ ].join('\n'));
271
+ } else {
272
+ console.log([
273
+ '[WhatsApp-Pi] Outgoing WhatsApp message result',
274
+ ` To: ${params.jid}`,
275
+ ' Status: failed',
276
+ ` Error: ${result.error ?? 'unknown error'}`
277
+ ].join('\n'));
253
278
  }
254
279
 
255
280
  return {