whatsapp-pi 1.0.35 → 1.0.37

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.35",
3
+ "version": "1.0.37",
4
4
  "type": "module",
5
5
  "description": "WhatsApp integration extension for Pi",
6
6
  "main": "whatsapp-pi.ts",
@@ -39,6 +39,7 @@
39
39
  "@mariozechner/pi-coding-agent": "latest",
40
40
  "@types/node": "^20.11.0",
41
41
  "@types/qrcode-terminal": "^0.12.2",
42
+ "@vitest/coverage-v8": "^1.6.1",
42
43
  "ts-node": "^10.9.2",
43
44
  "tsx": "^4.7.0",
44
45
  "typescript": "^5.3.0",
@@ -50,4 +51,4 @@
50
51
  ],
51
52
  "image": "https://upload.wikimedia.org/wikipedia/commons/thumb/6/6b/WhatsApp.svg/512px-WhatsApp.svg.png"
52
53
  }
53
- }
54
+ }
@@ -0,0 +1,66 @@
1
+ type ConsoleMethod = 'log' | 'info' | 'warn' | 'error';
2
+
3
+ const noisyBaileysPatterns = [
4
+ 'Failed to decrypt message with any known session',
5
+ 'Session error:',
6
+ 'Bad MAC',
7
+ 'Closing open session in favor of incoming prekey bundle',
8
+ 'Closing session:'
9
+ ];
10
+
11
+ const stringifyConsolePart = (part: unknown): string => {
12
+ if (part instanceof Error) {
13
+ return `${part.name}: ${part.message}\n${part.stack ?? ''}`;
14
+ }
15
+
16
+ if (typeof part === 'string') {
17
+ return part;
18
+ }
19
+
20
+ try {
21
+ return JSON.stringify(part);
22
+ } catch {
23
+ return String(part);
24
+ }
25
+ };
26
+
27
+ export const shouldSuppressBaileysConsoleMessage = (args: unknown[]): boolean => {
28
+ const message = args.map(stringifyConsolePart).join(' ');
29
+ return noisyBaileysPatterns.some(pattern => message.includes(pattern));
30
+ };
31
+
32
+ export const installBaileysConsoleFilter = (verbose: boolean): (() => void) => {
33
+ if (verbose) {
34
+ return () => {};
35
+ }
36
+
37
+ const methods: ConsoleMethod[] = ['log', 'info', 'warn', 'error'];
38
+ const originals = new Map<ConsoleMethod, (...args: any[]) => void>();
39
+ const patched = new Map<ConsoleMethod, (...args: any[]) => void>();
40
+
41
+ for (const method of methods) {
42
+ const original = console[method].bind(console);
43
+ originals.set(method, original);
44
+
45
+ const replacement = (...args: unknown[]) => {
46
+ if (shouldSuppressBaileysConsoleMessage(args)) {
47
+ return;
48
+ }
49
+
50
+ original(...args);
51
+ };
52
+
53
+ patched.set(method, replacement);
54
+ console[method] = replacement as typeof console[typeof method];
55
+ }
56
+
57
+ return () => {
58
+ for (const method of methods) {
59
+ const replacement = patched.get(method);
60
+ const original = originals.get(method);
61
+ if (replacement && original && console[method] === replacement) {
62
+ console[method] = original as typeof console[typeof method];
63
+ }
64
+ }
65
+ };
66
+ };
@@ -0,0 +1,123 @@
1
+ import { downloadContentFromMessage } from '@whiskeysockets/baileys';
2
+ import { mkdir, writeFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { AudioService } from './audio.service.js';
5
+ import type { IncomingResolution } from './incoming-message.resolver.js';
6
+ import { WhatsAppPiLogger } from './whatsapp-pi.logger.js';
7
+
8
+ export interface ProcessedIncomingContent {
9
+ text: string;
10
+ imageBuffer?: Buffer;
11
+ imageMimeType?: string;
12
+ }
13
+
14
+ export class IncomingMediaService {
15
+ constructor(
16
+ private readonly audioService: AudioService,
17
+ private readonly logger = new WhatsAppPiLogger(false)
18
+ ) {}
19
+
20
+ async process(resolved: IncomingResolution, pushName: string): Promise<ProcessedIncomingContent> {
21
+ if (resolved.kind === 'audio') {
22
+ return this.processAudio(resolved.audioMessage, pushName);
23
+ }
24
+
25
+ if (resolved.kind === 'image') {
26
+ return this.processImage(resolved.imageMessage, resolved.text, pushName);
27
+ }
28
+
29
+ if (resolved.kind === 'document') {
30
+ return this.processDocument(resolved.documentMessage, pushName);
31
+ }
32
+
33
+ return { text: resolved.text };
34
+ }
35
+
36
+ private async processAudio(audioMessage: any, pushName: string): Promise<ProcessedIncomingContent> {
37
+ this.logger.log(`[WhatsApp-Pi] Transcribing audio from ${pushName}...`);
38
+ const transcription = await this.audioService.transcribe(audioMessage);
39
+ return { text: `[Transcribed Audio]: ${transcription}` };
40
+ }
41
+
42
+ private async processImage(imageMessage: any, fallbackText: string, pushName: string): Promise<ProcessedIncomingContent> {
43
+ this.logger.log(`[WhatsApp-Pi] Downloading image from ${pushName}...`);
44
+
45
+ try {
46
+ const imageBuffer = await this.downloadMessage(imageMessage, 'image');
47
+ const rawMime = imageMessage.mimetype || 'image/jpeg';
48
+ let imageMimeType = rawMime.toLowerCase().split(';')[0].trim();
49
+ if (imageMimeType === 'image/jpg') imageMimeType = 'image/jpeg';
50
+
51
+ this.logger.log(`[WhatsApp-Pi] Image downloaded. MIME: ${imageMimeType} (original: ${rawMime}), Size: ${imageBuffer.length} bytes`);
52
+
53
+ return {
54
+ text: fallbackText || '[Image]',
55
+ imageBuffer,
56
+ imageMimeType
57
+ };
58
+ } catch (error) {
59
+ this.logger.error('[WhatsApp-Pi] Failed to download image:', error);
60
+ return { text: '[Image (download failed)]' };
61
+ }
62
+ }
63
+
64
+ private async processDocument(documentMessage: any, pushName: string): Promise<ProcessedIncomingContent> {
65
+ const fileName = documentMessage.fileName || 'unnamed_document';
66
+ const mimeType = documentMessage.mimetype || 'application/octet-stream';
67
+ const fileSize = documentMessage.fileLength ? Number(documentMessage.fileLength) : 0;
68
+
69
+ this.logger.log(`[WhatsApp-Pi] Downloading document from ${pushName}: ${fileName}...`);
70
+
71
+ try {
72
+ const buffer = await this.downloadMessage(documentMessage, 'document');
73
+ const relativePath = await this.saveDocument(fileName, buffer);
74
+
75
+ this.logger.log(`[WhatsApp-Pi] Document saved to ${relativePath} (${buffer.length} bytes)`);
76
+
77
+ let text = `[Document Received: ${fileName}]\n`
78
+ + `MIME Type: ${mimeType}\n`
79
+ + `Size: ${this.formatFileSize(fileSize)}\n`
80
+ + `Location: ${relativePath}`;
81
+
82
+ if (documentMessage.caption) {
83
+ text += `\n\nDescription: ${documentMessage.caption}`;
84
+ }
85
+
86
+ return { text };
87
+ } catch (error) {
88
+ this.logger.error('[WhatsApp-Pi] Failed to download document:', error);
89
+ return { text: `[Document: ${fileName} (download failed)]` };
90
+ }
91
+ }
92
+
93
+ private async downloadMessage(message: any, type: 'image' | 'document'): Promise<Buffer> {
94
+ const stream = await downloadContentFromMessage(message, type);
95
+ let buffer = Buffer.from([]);
96
+
97
+ for await (const chunk of stream) {
98
+ buffer = Buffer.concat([buffer, chunk]);
99
+ }
100
+
101
+ return buffer;
102
+ }
103
+
104
+ private async saveDocument(fileName: string, buffer: Buffer): Promise<string> {
105
+ const sanitized = fileName.replace(/[^a-z0-9\._-]/gi, '_');
106
+ const savedFileName = `${Date.now()}_${sanitized}`;
107
+ const documentDir = join(process.cwd(), '.pi-data', 'whatsapp', 'documents');
108
+ const absolutePath = join(documentDir, savedFileName);
109
+
110
+ await mkdir(documentDir, { recursive: true });
111
+ await writeFile(absolutePath, buffer);
112
+
113
+ return `./.pi-data/whatsapp/documents/${savedFileName}`;
114
+ }
115
+
116
+ private formatFileSize(fileSize: number): string {
117
+ if (fileSize > 1024 * 1024) {
118
+ return `${(fileSize / (1024 * 1024)).toFixed(1)} MB`;
119
+ }
120
+
121
+ return `${(fileSize / 1024).toFixed(1)} KB`;
122
+ }
123
+ }
@@ -0,0 +1,129 @@
1
+ import { extractMessageContent } from '@whiskeysockets/baileys';
2
+
3
+ export type IncomingResolution =
4
+ | { kind: 'text'; text: string }
5
+ | { kind: 'audio'; text: string; audioMessage: any }
6
+ | { kind: 'image'; text: string; imageMessage: any }
7
+ | { kind: 'document'; text: string; documentMessage: any }
8
+ | { kind: 'contact'; text: string }
9
+ | { kind: 'location'; text: string }
10
+ | { kind: 'system'; text: string }
11
+ | { kind: 'unsupported'; text: string };
12
+
13
+ const protocolTypes: Record<number, string> = {
14
+ 0: 'Message Deleted',
15
+ 3: 'Disappearing Messages Updated',
16
+ 4: 'Disappearing Message Sync Response',
17
+ 5: 'History Sync Notification',
18
+ 6: 'App State Sync Key Share',
19
+ 7: 'App State Sync Key Request',
20
+ 8: 'Message Backfill Request',
21
+ 9: 'Security Notification Sync',
22
+ 10: 'Fatal App State Sync Notification',
23
+ 11: 'Phone Number Shared',
24
+ 14: 'Message Edited',
25
+ 16: 'Peer Data Request',
26
+ 17: 'Peer Data Response',
27
+ 18: 'Welcome Message Request',
28
+ 19: 'Bot Feedback',
29
+ 20: 'Media Notification'
30
+ };
31
+
32
+ const unwrapMessageContent = (content: any): any => extractMessageContent(content) ?? content;
33
+
34
+ const getTypeName = (payload: any): string => {
35
+ if (!payload || typeof payload !== 'object') return 'unknown';
36
+ return Object.keys(payload)[0] || 'unknown';
37
+ };
38
+
39
+ const formatProtocolMessage = (protocolMessage: any): string => {
40
+ const typeLabel = protocolTypes[Number(protocolMessage?.type)] || 'System Update';
41
+ const editedText = protocolMessage?.editedMessage?.conversation
42
+ || protocolMessage?.editedMessage?.extendedTextMessage?.text;
43
+
44
+ if (editedText) {
45
+ return `[${typeLabel}: ${editedText}]`;
46
+ }
47
+
48
+ return `[${typeLabel}]`;
49
+ };
50
+
51
+ export const extractIncomingText = (message: any): IncomingResolution => {
52
+ const content = unwrapMessageContent(message);
53
+ const inner = content?.ephemeralMessage?.message
54
+ || content?.viewOnceMessage?.message
55
+ || content?.viewOnceMessageV2?.message
56
+ || content?.viewOnceMessageV2Extension?.message
57
+ || content?.message;
58
+
59
+ const resolved = inner ? unwrapMessageContent(inner) : content;
60
+ const typeName = getTypeName(resolved);
61
+ const protocolMessage = resolved?.protocolMessage
62
+ || (typeName === 'protocolMessage' ? resolved : undefined)
63
+ || content?.protocolMessage;
64
+
65
+ if (protocolMessage) {
66
+ return { kind: 'system', text: formatProtocolMessage(protocolMessage) };
67
+ }
68
+
69
+ if (resolved?.conversation) {
70
+ return { kind: 'text', text: resolved.conversation };
71
+ }
72
+
73
+ if (resolved?.extendedTextMessage?.text) {
74
+ return { kind: 'text', text: resolved.extendedTextMessage.text };
75
+ }
76
+
77
+ if (resolved?.imageMessage) {
78
+ return {
79
+ kind: 'image',
80
+ text: resolved.imageMessage.caption || '[Image]',
81
+ imageMessage: resolved.imageMessage
82
+ };
83
+ }
84
+
85
+ if (resolved?.videoMessage) {
86
+ return {
87
+ kind: 'text',
88
+ text: resolved.videoMessage.caption || '[Video]'
89
+ };
90
+ }
91
+
92
+ if (resolved?.audioMessage) {
93
+ return {
94
+ kind: 'audio',
95
+ text: '[Audio Message]',
96
+ audioMessage: resolved.audioMessage
97
+ };
98
+ }
99
+
100
+ if (resolved?.documentMessage) {
101
+ return {
102
+ kind: 'document',
103
+ text: resolved.documentMessage.caption || '[Document]',
104
+ documentMessage: resolved.documentMessage
105
+ };
106
+ }
107
+
108
+ if (resolved?.contactMessage || resolved?.contactsArrayMessage) {
109
+ return { kind: 'contact', text: '[Contact]' };
110
+ }
111
+
112
+ if (resolved?.locationMessage) {
113
+ return { kind: 'location', text: '[Location]' };
114
+ }
115
+
116
+ if (resolved?.buttonsResponseMessage?.selectedDisplayText) {
117
+ return { kind: 'text', text: resolved.buttonsResponseMessage.selectedDisplayText };
118
+ }
119
+
120
+ if (resolved?.listResponseMessage?.title) {
121
+ return { kind: 'text', text: resolved.listResponseMessage.title };
122
+ }
123
+
124
+ if (resolved?.templateButtonReplyMessage?.selectedDisplayText) {
125
+ return { kind: 'text', text: resolved.templateButtonReplyMessage.selectedDisplayText };
126
+ }
127
+
128
+ return { kind: 'unsupported', text: `[Unsupported Message Type: ${typeName}]` };
129
+ };
@@ -191,7 +191,6 @@ export class RecentsService {
191
191
  const normalizedNumber = this.normalizeNumber(senderNumber);
192
192
  const messages = this.store.messagesBySender[normalizedNumber] ?? [];
193
193
  return [...messages]
194
- .map(message => ({ ...message, timestamp: this.normalizeTimestamp(message.timestamp) }))
195
194
  .sort((left, right) => left.timestamp - right.timestamp)
196
195
  .slice(-20);
197
196
  }
@@ -0,0 +1,25 @@
1
+ export class WhatsAppPiLogger {
2
+ constructor(private verbose = false) {}
3
+
4
+ setVerbose(verbose: boolean) {
5
+ this.verbose = verbose;
6
+ }
7
+
8
+ log(message: string, ...args: unknown[]) {
9
+ if (this.verbose) {
10
+ console.log(message, ...args);
11
+ }
12
+ }
13
+
14
+ warn(message: string, ...args: unknown[]) {
15
+ if (this.verbose) {
16
+ console.warn(message, ...args);
17
+ }
18
+ }
19
+
20
+ error(message: string, ...args: unknown[]) {
21
+ if (this.verbose) {
22
+ console.error(message, ...args);
23
+ }
24
+ }
25
+ }
@@ -10,6 +10,11 @@ import { Boom } from '@hapi/boom';
10
10
  import { SessionManager } from './session.manager.js';
11
11
  import { IncomingMessage, WhatsAppSession, SessionStatus } from '../models/whatsapp.types.js';
12
12
  import { MessageSender } from './message.sender.js';
13
+ import { installBaileysConsoleFilter } from './baileys-console-filter.js';
14
+
15
+ export interface WhatsAppStartOptions {
16
+ allowPairingOnAuthFailure?: boolean;
17
+ }
13
18
 
14
19
  export class WhatsAppService {
15
20
  private socket: any;
@@ -19,6 +24,7 @@ export class WhatsAppService {
19
24
  private verboseMode = false;
20
25
  private onIncomingMessageRecorded?: (message: IncomingMessage) => void | Promise<void>;
21
26
  private saveCreds?: () => Promise<void>;
27
+ private restoreBaileysConsoleFilter?: () => void;
22
28
 
23
29
  constructor(sessionManager: SessionManager) {
24
30
  this.sessionManager = sessionManager;
@@ -29,6 +35,15 @@ export class WhatsAppService {
29
35
  return this.sessionManager.getStatus();
30
36
  }
31
37
 
38
+ public getEffectiveStatus(): SessionStatus {
39
+ const status = this.sessionManager.getStatus();
40
+ if (status === 'connected' && !this.socket) {
41
+ return 'disconnected';
42
+ }
43
+
44
+ return status;
45
+ }
46
+
32
47
  public setIncomingMessageRecorder(callback: (message: IncomingMessage) => void | Promise<void>) {
33
48
  this.onIncomingMessageRecorded = callback;
34
49
  }
@@ -43,6 +58,10 @@ export class WhatsAppService {
43
58
 
44
59
  public setVerboseMode(verbose: boolean) {
45
60
  this.verboseMode = verbose;
61
+ if (verbose) {
62
+ this.restoreBaileysConsoleFilter?.();
63
+ this.restoreBaileysConsoleFilter = undefined;
64
+ }
46
65
  }
47
66
 
48
67
  private normalizeContactNumber(value: string): string {
@@ -57,7 +76,8 @@ export class WhatsAppService {
57
76
  return value;
58
77
  }
59
78
 
60
- async start() {
79
+ async start(options: WhatsAppStartOptions = {}) {
80
+ const allowPairingOnAuthFailure = options.allowPairingOnAuthFailure ?? true;
61
81
  if (this.isReconnecting) return;
62
82
  this.onStatusUpdate?.('| WhatsApp: Connecting...');
63
83
 
@@ -67,6 +87,8 @@ export class WhatsAppService {
67
87
 
68
88
  // Cleanup existing socket if any
69
89
  if (this.socket) {
90
+ this.restoreBaileysConsoleFilter?.();
91
+ this.restoreBaileysConsoleFilter = undefined;
70
92
  this.socket.ev.removeAllListeners('connection.update');
71
93
  this.socket.ev.removeAllListeners('creds.update');
72
94
  this.socket.ev.removeAllListeners('messages.upsert');
@@ -127,32 +149,57 @@ export class WhatsAppService {
127
149
  const statusCode = (lastDisconnect?.error as Boom)?.output?.statusCode;
128
150
  const errorMessage = lastDisconnect?.error?.message || '';
129
151
  const shouldReconnect = statusCode !== DisconnectReason.loggedOut;
130
- const shouldTreatAsLoggedOut =
152
+ const isBadMac = errorMessage.includes('Bad MAC');
153
+ const isAuthRejected =
131
154
  errorMessage.includes('bad-request') ||
132
- errorMessage.includes('Bad MAC') ||
133
155
  statusCode === 400 ||
134
156
  statusCode === 401 ||
135
157
  statusCode === DisconnectReason.loggedOut ||
136
158
  statusCode === DisconnectReason.badSession;
159
+ const shouldTreatAsLoggedOut = isBadMac || isAuthRejected;
137
160
 
138
- console.error(`Connection closed [${statusCode}]. Reconnecting: ${shouldReconnect}`);
161
+ if (this.verboseMode) {
162
+ console.error(`Connection closed [${statusCode}]. Reconnecting: ${shouldReconnect}`);
163
+ }
139
164
 
140
165
  if (shouldTreatAsLoggedOut) {
141
- console.error(`Session invalid or logged out [${statusCode}] - preserving auth state and requiring re-auth`);
142
- if (errorMessage.includes('Bad MAC')) {
143
- console.error('[WhatsApp-Pi] Bad MAC error detected. Your session keys are corrupted.');
144
- console.error('[WhatsApp-Pi] Run /whatsapp-logout to clear auth state, then reconnect with /whatsapp-connect');
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
+ }
145
190
  this.onStatusUpdate?.('| WhatsApp: Session Error (Bad MAC)');
146
191
  }
147
192
  this.sessionManager.setStatus('logged-out');
148
- if (!errorMessage.includes('Bad MAC')) {
193
+ if (!isBadMac) {
149
194
  this.onStatusUpdate?.('| WhatsApp: Logged out');
150
195
  }
151
196
  return;
152
197
  }
153
198
 
154
199
  if (statusCode === DisconnectReason.connectionReplaced) {
155
- console.error('Connection replaced - another instance connected');
200
+ if (this.verboseMode) {
201
+ console.error('Connection replaced - another instance connected');
202
+ }
156
203
  this.onStatusUpdate?.('| WhatsApp: Conflict (Another Instance)');
157
204
  return;
158
205
  }
@@ -162,7 +209,7 @@ export class WhatsAppService {
162
209
  this.onStatusUpdate?.('| WhatsApp: Reconnecting...');
163
210
  setTimeout(() => {
164
211
  this.isReconnecting = false;
165
- this.start();
212
+ this.start(options);
166
213
  }, 3000);
167
214
  } else if (!shouldReconnect) {
168
215
  this.sessionManager.setStatus('logged-out');
@@ -187,6 +234,7 @@ export class WhatsAppService {
187
234
  console.log = originalConsoleLog;
188
235
  console.warn = originalConsoleWarn;
189
236
  console.error = originalConsoleError;
237
+ this.restoreBaileysConsoleFilter = installBaileysConsoleFilter(this.verboseMode);
190
238
  }
191
239
  }
192
240
 
@@ -354,6 +402,8 @@ export class WhatsAppService {
354
402
  }
355
403
 
356
404
  if (this.socket) {
405
+ this.restoreBaileysConsoleFilter?.();
406
+ this.restoreBaileysConsoleFilter = undefined;
357
407
  this.socket.ev.removeAllListeners('connection.update');
358
408
  this.socket.ev.removeAllListeners('creds.update');
359
409
  this.socket.ev.removeAllListeners('messages.upsert');
@@ -13,7 +13,7 @@ export class MenuHandler {
13
13
  ) {}
14
14
 
15
15
  async handleCommand(ctx: ExtensionCommandContext) {
16
- const status = this.sessionManager.getStatus();
16
+ const status = this.whatsappService.getEffectiveStatus();
17
17
  const registered = await this.sessionManager.isRegistered();
18
18
  const options: string[] = [];
19
19
 
@@ -178,12 +178,7 @@ export class MenuHandler {
178
178
  const choice = await ctx.ui.select('Blocked Numbers (Select to Manage)', options);
179
179
 
180
180
  if (choice && choice !== 'Back') {
181
- let num = choice;
182
- if (num.includes('(')) {
183
- const match = num.match(/\((.*?)\)/);
184
- if (match) num = match[1];
185
- }
186
- await this.manageBlockedNumber(ctx, num);
181
+ await this.manageBlockedNumber(ctx, this.parseContactNumberOption(choice));
187
182
  } else {
188
183
  await this.handleCommand(ctx);
189
184
  }
@@ -301,36 +296,33 @@ export class MenuHandler {
301
296
  }
302
297
 
303
298
  private async sendMessageFromRecents(ctx: ExtensionCommandContext, conversation: RecentConversationSummary) {
304
- const displayName = this.getConversationDisplayName(conversation);
305
- for (let attempt = 0; attempt < 2; attempt++) {
306
- const text = await ctx.ui.input(`Send a message to ${displayName}:`);
307
- const trimmed = text?.trim() || '';
308
-
309
- if (!trimmed) {
310
- ctx.ui.notify('Please enter a message before sending.', 'error');
311
- continue;
312
- }
313
-
314
- const result = await this.whatsappService.sendMenuMessage(this.toJid(conversation.senderNumber), trimmed);
315
- if (result.success) {
316
- await this.recentsService.recordMessage({
317
- messageId: result.messageId ?? `${Date.now()}`,
318
- senderNumber: conversation.senderNumber,
319
- senderName: conversation.senderName,
320
- text: trimmed,
321
- direction: 'outgoing',
322
- timestamp: Date.now()
323
- });
324
- ctx.ui.notify(`Sent message to ${displayName}`, 'info');
325
- } else {
326
- ctx.ui.notify(`Failed to send message to ${displayName}: ${result.error ?? 'Unknown error'}`, 'error');
327
- }
328
- return;
329
- }
299
+ await this.sendPromptedMenuMessage(ctx, {
300
+ displayName: this.getConversationDisplayName(conversation),
301
+ senderNumber: conversation.senderNumber,
302
+ senderName: conversation.senderName,
303
+ appendPiSuffix: false
304
+ });
330
305
  }
331
306
 
332
307
  private async sendMessageToAllowedNumber(ctx: ExtensionCommandContext, contact: Contact) {
333
- const displayName = contact.name ? `${contact.name} (${contact.number})` : contact.number;
308
+ await this.sendPromptedMenuMessage(ctx, {
309
+ displayName: this.formatAllowedContactOption(contact),
310
+ senderNumber: contact.number,
311
+ senderName: contact.name,
312
+ appendPiSuffix: true
313
+ });
314
+ }
315
+
316
+ private async sendPromptedMenuMessage(
317
+ ctx: ExtensionCommandContext,
318
+ options: {
319
+ displayName: string;
320
+ senderNumber: string;
321
+ senderName?: string;
322
+ appendPiSuffix: boolean;
323
+ }
324
+ ) {
325
+ const { displayName, senderNumber, senderName, appendPiSuffix } = options;
334
326
  for (let attempt = 0; attempt < 2; attempt++) {
335
327
  const inputText = (await ctx.ui.input(`Send a message to ${displayName}:`))?.trim() || '';
336
328
 
@@ -339,15 +331,14 @@ export class MenuHandler {
339
331
  continue;
340
332
  }
341
333
 
342
- const inputTextWithPiSuffix = inputText + ' π';
343
-
344
- const result = await this.whatsappService.sendMenuMessage(this.toJid(contact.number), inputTextWithPiSuffix);
334
+ const messageText = appendPiSuffix ? `${inputText} π` : inputText;
335
+ const result = await this.whatsappService.sendMenuMessage(this.toJid(senderNumber), messageText);
345
336
  if (result.success) {
346
337
  await this.recentsService.recordMessage({
347
338
  messageId: result.messageId ?? `${Date.now()}`,
348
- senderNumber: contact.number,
349
- senderName: contact.name,
350
- text: inputTextWithPiSuffix,
339
+ senderNumber,
340
+ senderName,
341
+ text: messageText,
351
342
  direction: 'outgoing',
352
343
  timestamp: Date.now()
353
344
  });
@@ -372,7 +363,8 @@ export class MenuHandler {
372
363
  }
373
364
 
374
365
  const options = [
375
- ...history.slice().reverse().map(message => this.formatHistoryOption(message.timestamp, message.direction, message.text)),
366
+ ...this.sortHistoryByMostRecent(history)
367
+ .map(message => this.formatHistoryOption(message.timestamp, message.direction, message.text)),
376
368
  'Back'
377
369
  ];
378
370
 
@@ -404,6 +396,39 @@ export class MenuHandler {
404
396
  return contact.name ? `${contact.name} ${contact.number}` : contact.number;
405
397
  }
406
398
 
399
+ private parseContactNumberOption(choice: string): string {
400
+ if (!choice.includes('(')) {
401
+ return choice;
402
+ }
403
+
404
+ const match = choice.match(/\((.*?)\)/);
405
+ return match?.[1] ?? choice;
406
+ }
407
+
408
+ private sortHistoryByMostRecent<T extends { timestamp: number }>(history: T[]): T[] {
409
+ return [...history].sort((left, right) => {
410
+ const dayComparison = this.getDayStart(right.timestamp) - this.getDayStart(left.timestamp);
411
+ if (dayComparison !== 0) {
412
+ return dayComparison;
413
+ }
414
+
415
+ return this.getTimeOfDay(right.timestamp) - this.getTimeOfDay(left.timestamp);
416
+ });
417
+ }
418
+
419
+ private getTimeOfDay(timestamp: number): number {
420
+ const date = new Date(timestamp);
421
+ return date.getHours() * 60 * 60 * 1000
422
+ + date.getMinutes() * 60 * 1000
423
+ + date.getSeconds() * 1000
424
+ + date.getMilliseconds();
425
+ }
426
+
427
+ private getDayStart(timestamp: number): number {
428
+ const date = new Date(timestamp);
429
+ return new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
430
+ }
431
+
407
432
  private formatHistoryOption(timestamp: number, direction: string, text: string): string {
408
433
  const marker = direction === 'outgoing' ? 'Sent' : 'Received';
409
434
  const displayText = this.truncate(text, 60) || '[No text]';
package/whatsapp-pi.ts CHANGED
@@ -1,15 +1,13 @@
1
- import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@mariozechner/pi-coding-agent";
1
+ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
2
2
  import { Type } from "@sinclair/typebox";
3
- import { extractMessageContent } from '@whiskeysockets/baileys';
4
3
  import { SessionManager } from './src/services/session.manager.js';
5
4
  import { WhatsAppService } from './src/services/whatsapp.service.js';
6
5
  import { MenuHandler } from './src/ui/menu.handler.js';
7
6
  import { RecentsService } from './src/services/recents.service.js';
8
7
  import { AudioService } from './src/services/audio.service.js';
9
- import { join } from 'node:path';
10
- import { writeFile, mkdir } from 'node:fs/promises';
11
-
12
- console.log("[WhatsApp-Pi] Extension file loaded by Pi...");
8
+ import { extractIncomingText } from './src/services/incoming-message.resolver.js';
9
+ import { IncomingMediaService } from './src/services/incoming-media.service.js';
10
+ import { WhatsAppPiLogger } from './src/services/whatsapp-pi.logger.js';
13
11
 
14
12
  const shutdownState = globalThis as typeof globalThis & {
15
13
  __whatsappPiShutdown?: {
@@ -36,6 +34,8 @@ export default function (pi: ExtensionAPI) {
36
34
  const whatsappService = new WhatsAppService(sessionManager);
37
35
  const recentsService = new RecentsService(sessionManager);
38
36
  const audioService = new AudioService();
37
+ const logger = new WhatsAppPiLogger(false);
38
+ const incomingMediaService = new IncomingMediaService(audioService, logger);
39
39
  const menuHandler = new MenuHandler(whatsappService, sessionManager, recentsService);
40
40
  let _ctx: ExtensionContext | undefined;
41
41
 
@@ -46,11 +46,12 @@ export default function (pi: ExtensionAPI) {
46
46
  }
47
47
 
48
48
  shutdownState.__whatsappPiShutdown.installed = true;
49
+
49
50
  const shutdown = async (reason: string) => {
50
51
  try {
51
52
  await shutdownState.__whatsappPiShutdown?.stop?.();
52
53
  } catch (error) {
53
- console.error(`[WhatsApp-Pi] Graceful shutdown failed during ${reason}:`, error);
54
+ logger.error(`[WhatsApp-Pi] Graceful shutdown failed during ${reason}:`, error);
54
55
  }
55
56
  };
56
57
 
@@ -59,7 +60,7 @@ export default function (pi: ExtensionAPI) {
59
60
  };
60
61
 
61
62
  // Initial status setup
62
- pi.on("session_start", async (_event, ctx) => {
63
+ pi.on("session_start", async (event, ctx) => {
63
64
  _ctx = ctx;
64
65
  // Check verbose mode
65
66
  const isVerboseFlagSet = process.argv.includes("--verbose");
@@ -67,9 +68,10 @@ export default function (pi: ExtensionAPI) {
67
68
  const isVerbose = isVerboseFlagSet;
68
69
 
69
70
  whatsappService.setVerboseMode(isVerbose);
71
+ logger.setVerbose(isVerbose);
70
72
 
71
73
  if (isVerbose) {
72
- console.log('[WhatsApp-Pi] Verbose mode enabled - Baileys trace logs will be shown');
74
+ logger.log('[WhatsApp-Pi] Verbose mode enabled - Baileys trace logs will be shown');
73
75
  }
74
76
  ctx.ui.setStatus('whatsapp', '| WhatsApp: Disconnected');
75
77
  whatsappService.setStatusCallback((status) => {
@@ -98,10 +100,16 @@ export default function (pi: ExtensionAPI) {
98
100
  const savedStateEntry = [...ctx.sessionManager.getEntries()]
99
101
  .reverse()
100
102
  .find(entry => entry.type === "custom" && entry.customType === "whatsapp-state");
103
+ const isWhatsappPiOn = event.reason === "startup" && pi.getFlag("whatsapp-pi-online") === true;
101
104
 
102
105
  if (savedStateEntry) {
103
106
  const data = (savedStateEntry as { data?: any }).data;
104
- if (data.status) await sessionManager.setStatus(data.status);
107
+ if (data.status) {
108
+ const restoredStatus = data.status === 'connected' && !isWhatsappPiOn
109
+ ? 'disconnected'
110
+ : data.status;
111
+ await sessionManager.setStatus(restoredStatus);
112
+ }
105
113
  if (Array.isArray(data.allowList)) {
106
114
  for (const n of data.allowList) {
107
115
  const num = typeof n === "string" ? n : n.number;
@@ -111,12 +119,11 @@ export default function (pi: ExtensionAPI) {
111
119
  }
112
120
  }
113
121
 
114
- // Check whatsapp flag
115
- const isWhatsappPiOn = process.argv.includes("--whatsapp-pi-online");
122
+ // Check whatsapp flag — only auto-connect on initial startup, not reloads/new sessions
116
123
  const registered = await sessionManager.isRegistered();
117
124
 
118
- if (registered || isWhatsappPiOn) {
119
- ctx.ui.setStatus('whatsapp', registered ? '| WhatsApp: Reconnecting...' : '| WhatsApp: Auto-connecting...');
125
+ if (isWhatsappPiOn && registered) {
126
+ ctx.ui.setStatus('whatsapp', '| WhatsApp: Auto-connecting...');
120
127
 
121
128
  // Retry logic (max 3 attempts, 3s delay)
122
129
  let attempts = 0;
@@ -125,7 +132,7 @@ export default function (pi: ExtensionAPI) {
125
132
  const tryConnect = async () => {
126
133
  attempts++;
127
134
  try {
128
- await whatsappService.start();
135
+ await whatsappService.start({ allowPairingOnAuthFailure: false });
129
136
  } catch (error) {
130
137
  if (attempts < maxAttempts) {
131
138
  ctx.ui.notify(`WhatsApp: Connection attempt ${attempts} failed. Retrying...`, 'warning');
@@ -152,141 +159,10 @@ export default function (pi: ExtensionAPI) {
152
159
  }
153
160
  } catch (e) {
154
161
  ctx.ui.notify('WhatsApp: pdftotext not found. PDF document support will be limited to storage only.', 'warning');
155
- console.warn('[WhatsApp-Pi] Warning: pdftotext not found in system PATH.');
162
+ logger.warn('[WhatsApp-Pi] Warning: pdftotext not found in system PATH.');
156
163
  }
157
164
  });
158
165
 
159
-
160
-
161
- type IncomingResolution =
162
- | { kind: 'text'; text: string }
163
- | { kind: 'audio'; text: string; audioMessage: any }
164
- | { kind: 'image'; text: string; imageMessage: any }
165
- | { kind: 'document'; text: string; documentMessage: any }
166
- | { kind: 'contact'; text: string }
167
- | { kind: 'location'; text: string }
168
- | { kind: 'system'; text: string }
169
- | { kind: 'unsupported'; text: string };
170
-
171
- const extractIncomingText = (message: any, pushName: string): IncomingResolution => {
172
- const unwrap = (content: any): any => extractMessageContent(content) ?? content;
173
- const content = unwrap(message);
174
-
175
- const getTypeName = (payload: any): string => {
176
- if (!payload || typeof payload !== 'object') return 'unknown';
177
- const keys = Object.keys(payload);
178
- return keys[0] || 'unknown';
179
- };
180
-
181
- const formatProtocolMessage = (protocolMessage: any): string => {
182
- const protocolTypes: Record<number, string> = {
183
- 0: 'Message Deleted',
184
- 3: 'Disappearing Messages Updated',
185
- 4: 'Disappearing Message Sync Response',
186
- 5: 'History Sync Notification',
187
- 6: 'App State Sync Key Share',
188
- 7: 'App State Sync Key Request',
189
- 8: 'Message Backfill Request',
190
- 9: 'Security Notification Sync',
191
- 10: 'Fatal App State Sync Notification',
192
- 11: 'Phone Number Shared',
193
- 14: 'Message Edited',
194
- 16: 'Peer Data Request',
195
- 17: 'Peer Data Response',
196
- 18: 'Welcome Message Request',
197
- 19: 'Bot Feedback',
198
- 20: 'Media Notification'
199
- };
200
-
201
- const typeLabel = protocolTypes[Number(protocolMessage?.type)] || 'System Update';
202
- if (protocolMessage?.editedMessage?.conversation || protocolMessage?.editedMessage?.extendedTextMessage?.text) {
203
- const editedText = protocolMessage.editedMessage.conversation
204
- || protocolMessage.editedMessage.extendedTextMessage?.text
205
- || '[Edited message]';
206
- return `[${typeLabel}: ${editedText}]`;
207
- }
208
-
209
- return `[${typeLabel}]`;
210
- };
211
-
212
- const inner = content?.ephemeralMessage?.message
213
- || content?.viewOnceMessage?.message
214
- || content?.viewOnceMessageV2?.message
215
- || content?.viewOnceMessageV2Extension?.message
216
- || content?.message;
217
-
218
- const resolved = inner ? unwrap(inner) : content;
219
- const typeName = getTypeName(resolved);
220
- const protocolMessage = resolved?.protocolMessage
221
- || (typeName === 'protocolMessage' ? resolved : undefined)
222
- || content?.protocolMessage;
223
-
224
- if (protocolMessage) {
225
- return { kind: 'system', text: formatProtocolMessage(protocolMessage) };
226
- }
227
-
228
- if (resolved?.conversation) {
229
- return { kind: 'text', text: resolved.conversation };
230
- }
231
-
232
- if (resolved?.extendedTextMessage?.text) {
233
- return { kind: 'text', text: resolved.extendedTextMessage.text };
234
- }
235
-
236
- if (resolved?.imageMessage) {
237
- return {
238
- kind: 'image',
239
- text: resolved.imageMessage.caption || '[Image]',
240
- imageMessage: resolved.imageMessage
241
- };
242
- }
243
-
244
- if (resolved?.videoMessage) {
245
- return {
246
- kind: 'text',
247
- text: resolved.videoMessage.caption || '[Video]'
248
- };
249
- }
250
-
251
- if (resolved?.audioMessage) {
252
- return {
253
- kind: 'audio',
254
- text: '[Audio Message]',
255
- audioMessage: resolved.audioMessage
256
- };
257
- }
258
-
259
- if (resolved?.documentMessage) {
260
- return {
261
- kind: 'document',
262
- text: resolved.documentMessage.caption || '[Document]',
263
- documentMessage: resolved.documentMessage
264
- };
265
- }
266
-
267
- if (resolved?.contactMessage || resolved?.contactsArrayMessage) {
268
- return { kind: 'contact', text: '[Contact]' };
269
- }
270
-
271
- if (resolved?.locationMessage) {
272
- return { kind: 'location', text: '[Location]' };
273
- }
274
-
275
- if (resolved?.buttonsResponseMessage?.selectedDisplayText) {
276
- return { kind: 'text', text: resolved.buttonsResponseMessage.selectedDisplayText };
277
- }
278
-
279
- if (resolved?.listResponseMessage?.title) {
280
- return { kind: 'text', text: resolved.listResponseMessage.title };
281
- }
282
-
283
- if (resolved?.templateButtonReplyMessage?.selectedDisplayText) {
284
- return { kind: 'text', text: resolved.templateButtonReplyMessage.selectedDisplayText };
285
- }
286
-
287
- return { kind: 'unsupported', text: `[Unsupported Message Type: ${typeName}]` };
288
- };
289
-
290
166
  // Handle incoming messages by injecting them as user prompts
291
167
  whatsappService.setMessageCallback(async (m) => {
292
168
  const msg = m.messages[0];
@@ -302,99 +178,15 @@ export default function (pi: ExtensionAPI) {
302
178
  whatsappService.sendPresence(remoteJid, 'composing');
303
179
  }
304
180
 
305
- const resolved = extractIncomingText(msg.message, pushName);
181
+ const resolved = extractIncomingText(msg.message);
306
182
  if (resolved.kind === 'system') {
307
- console.log(`[WhatsApp-Pi] ${pushName} (${sender}): ${resolved.text}`);
183
+ logger.log(`[WhatsApp-Pi] ${pushName} (${sender}): ${resolved.text}`);
308
184
  return;
309
185
  }
310
186
 
311
- let text = resolved.text;
312
-
313
- // Handle media types
314
- let imageBuffer: Buffer | undefined;
315
- let imageMimeType: string | undefined;
316
-
317
- if (resolved.kind === 'audio') {
318
- console.log(`[WhatsApp-Pi] Transcribing audio from ${pushName}...`);
319
- const transcription = await audioService.transcribe(resolved.audioMessage);
320
- text = `[Transcribed Audio]: ${transcription}`;
321
- } else if (resolved.kind === 'image') {
322
- console.log(`[WhatsApp-Pi] Downloading image from ${pushName}...`);
323
- try {
324
- const { downloadContentFromMessage } = await import('@whiskeysockets/baileys');
325
- const stream = await downloadContentFromMessage(resolved.imageMessage, 'image');
326
- let buffer = Buffer.from([]);
327
- for await (const chunk of stream) {
328
- buffer = Buffer.concat([buffer, chunk]);
329
- }
330
- imageBuffer = buffer;
331
-
332
- // Normalize mime type for Cloud Code Assist / Gemini
333
- let rawMime = resolved.imageMessage.mimetype || 'image/jpeg';
334
- imageMimeType = rawMime.toLowerCase().split(';')[0].trim();
335
- if (imageMimeType === 'image/jpg') imageMimeType = 'image/jpeg';
336
-
337
- console.log(`[WhatsApp-Pi] Image downloaded. MIME: ${imageMimeType} (original: ${rawMime}), Size: ${imageBuffer.length} bytes`);
338
-
339
- text = resolved.text || "[Image]";
340
- } catch (e) {
341
- console.error(`[WhatsApp-Pi] Failed to download image:`, e);
342
- text = "[Image (download failed)]";
343
- }
344
- } else if (resolved.kind === 'document') {
345
- const doc = resolved.documentMessage;
346
- const fileName = doc.fileName || 'unnamed_document';
347
- const mimeType = doc.mimetype || 'application/octet-stream';
348
- const fileSize = doc.fileLength ? Number(doc.fileLength) : 0;
349
-
350
- console.log(`[WhatsApp-Pi] Downloading document from ${pushName}: ${fileName}...`);
351
-
352
- try {
353
- const { downloadContentFromMessage } = await import('@whiskeysockets/baileys');
354
- const stream = await downloadContentFromMessage(doc, 'document');
355
- let buffer = Buffer.from([]);
356
- for await (const chunk of stream) {
357
- buffer = Buffer.concat([buffer, chunk]);
358
- }
359
-
360
- // Sanitize filename
361
- const sanitized = fileName.replace(/[^a-z0-9\._-]/gi, '_');
362
- const savedFileName = `${Date.now()}_${sanitized}`;
363
- const relativePath = `./.pi-data/whatsapp/documents/${savedFileName}`;
364
- const absolutePath = join(process.cwd(), '.pi-data', 'whatsapp', 'documents', savedFileName);
365
-
366
- // Ensure directory exists (T001 handles it at startup, but let's be safe)
367
- await mkdir(join(process.cwd(), '.pi-data', 'whatsapp', 'documents'), { recursive: true });
368
- await writeFile(absolutePath, buffer);
369
-
370
- console.log(`[WhatsApp-Pi] Document saved to ${relativePath} (${buffer.length} bytes)`);
371
-
372
- const sizeFormatted = fileSize > 1024 * 1024
373
- ? `${(fileSize / (1024 * 1024)).toFixed(1)} MB`
374
- : `${(fileSize / 1024).toFixed(1)} KB`;
375
-
376
- text = `[Document Received: ${fileName}]\n` +
377
- `MIME Type: ${mimeType}\n` +
378
- `Size: ${sizeFormatted}\n` +
379
- `Location: ${relativePath}`;
380
-
381
- if (doc.caption) {
382
- text += `\n\nDescription: ${doc.caption}`;
383
- }
384
- } catch (e) {
385
- console.error(`[WhatsApp-Pi] Failed to download document:`, e);
386
- text = `[Document: ${fileName} (download failed)]`;
387
- }
388
- } else if (resolved.kind === 'contact') {
389
- text = resolved.text;
390
- } else if (resolved.kind === 'location') {
391
- text = resolved.text;
392
- } else if (resolved.kind === 'unsupported') {
393
- text = resolved.text;
394
- }
187
+ const { text, imageBuffer, imageMimeType } = await incomingMediaService.process(resolved, pushName);
395
188
 
396
- // Always log to console so it appears in the TUI log pane
397
- console.log(`[WhatsApp-Pi] ${pushName} (${sender}): ${text}`);
189
+ logger.log(`[WhatsApp-Pi] ${pushName} (${sender}): ${text}`);
398
190
 
399
191
  // Use a standard delivery for ALL messages to ensure TUI consistency
400
192
  if (imageBuffer && imageMimeType) {
@@ -408,7 +200,7 @@ export default function (pi: ExtensionAPI) {
408
200
 
409
201
  // Handle commands
410
202
  if (text.trim().toLowerCase().startsWith('/compact')) {
411
- console.log(`[WhatsApp-Pi] Session compact requested by ${pushName}.`);
203
+ logger.log(`[WhatsApp-Pi] Session compact requested by ${pushName}.`);
412
204
 
413
205
  if (_ctx) {
414
206
  _ctx.compact();
@@ -418,7 +210,7 @@ export default function (pi: ExtensionAPI) {
418
210
  }
419
211
 
420
212
  if (text.trim().toLowerCase().startsWith('/abort')) {
421
- console.log(`[WhatsApp-Pi] Abort requested by ${pushName}.`);
213
+ logger.log(`[WhatsApp-Pi] Abort requested by ${pushName}.`);
422
214
  if (_ctx) {
423
215
  _ctx.abort();
424
216
  await whatsappService.sendMessage(remoteJid!, "Aborted! ✅");
@@ -524,7 +316,7 @@ export default function (pi: ExtensionAPI) {
524
316
  });
525
317
 
526
318
  pi.on("session_shutdown", async () => {
527
- console.log("[WhatsApp-Pi] Session shutdown detected. Stopping WhatsApp service...");
319
+ logger.log("[WhatsApp-Pi] Session shutdown detected. Stopping WhatsApp service...");
528
320
  await whatsappService.stop();
529
321
  });
530
322
  }