whatsapp-pi 1.0.36 → 1.0.38

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.36",
3
+ "version": "1.0.38",
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');
@@ -3,7 +3,7 @@ import { SessionManager, type Contact } from '../services/session.manager.js';
3
3
  import { validatePhoneNumber, type RecentConversationSummary } from '../models/whatsapp.types.js';
4
4
  import { RecentsService } from '../services/recents.service.js';
5
5
  import * as qrcode from 'qrcode-terminal';
6
- import type { ExtensionCommandContext } from '@mariozechner/pi-coding-agent';
6
+ import { copyToClipboard, type ExtensionCommandContext } from '@mariozechner/pi-coding-agent';
7
7
 
8
8
  export class MenuHandler {
9
9
  constructor(
@@ -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
 
@@ -107,7 +107,7 @@ export class MenuHandler {
107
107
 
108
108
  private async manageAllowedContact(ctx: ExtensionCommandContext, contact: Contact) {
109
109
  const displayName = this.formatAllowedContactOption(contact);
110
- const options = ['Send Message', 'History'];
110
+ const options = ['Send Message', 'History', 'Copy Number'];
111
111
  if (contact.name) {
112
112
  options.push('Remove Alias');
113
113
  } else {
@@ -129,6 +129,12 @@ export class MenuHandler {
129
129
  return;
130
130
  }
131
131
 
132
+ if (choice === 'Copy Number') {
133
+ await this.copyAllowedNumber(ctx, contact);
134
+ await this.manageAllowedContact(ctx, contact);
135
+ return;
136
+ }
137
+
132
138
  if (choice === 'Add Alias') {
133
139
  const alias = await ctx.ui.input(`Enter alias for ${contact.number}:`);
134
140
  const trimmedAlias = alias?.trim() || '';
@@ -165,6 +171,15 @@ export class MenuHandler {
165
171
  await this.manageAllowList(ctx);
166
172
  }
167
173
 
174
+ private async copyAllowedNumber(ctx: ExtensionCommandContext, contact: Contact) {
175
+ try {
176
+ await copyToClipboard(contact.number);
177
+ ctx.ui.notify(`Copied ${contact.number} to clipboard`, 'info');
178
+ } catch {
179
+ ctx.ui.notify(`Failed to copy ${contact.number} to clipboard`, 'error');
180
+ }
181
+ }
182
+
168
183
  private async manageBlockList(ctx: ExtensionCommandContext) {
169
184
  const list = [...this.sessionManager.getIgnoredNumbers()].reverse();
170
185
 
@@ -178,12 +193,7 @@ export class MenuHandler {
178
193
  const choice = await ctx.ui.select('Blocked Numbers (Select to Manage)', options);
179
194
 
180
195
  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);
196
+ await this.manageBlockedNumber(ctx, this.parseContactNumberOption(choice));
187
197
  } else {
188
198
  await this.handleCommand(ctx);
189
199
  }
@@ -301,36 +311,33 @@ export class MenuHandler {
301
311
  }
302
312
 
303
313
  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
- }
314
+ await this.sendPromptedMenuMessage(ctx, {
315
+ displayName: this.getConversationDisplayName(conversation),
316
+ senderNumber: conversation.senderNumber,
317
+ senderName: conversation.senderName,
318
+ appendPiSuffix: false
319
+ });
330
320
  }
331
321
 
332
322
  private async sendMessageToAllowedNumber(ctx: ExtensionCommandContext, contact: Contact) {
333
- const displayName = contact.name ? `${contact.name} (${contact.number})` : contact.number;
323
+ await this.sendPromptedMenuMessage(ctx, {
324
+ displayName: this.formatAllowedContactOption(contact),
325
+ senderNumber: contact.number,
326
+ senderName: contact.name,
327
+ appendPiSuffix: true
328
+ });
329
+ }
330
+
331
+ private async sendPromptedMenuMessage(
332
+ ctx: ExtensionCommandContext,
333
+ options: {
334
+ displayName: string;
335
+ senderNumber: string;
336
+ senderName?: string;
337
+ appendPiSuffix: boolean;
338
+ }
339
+ ) {
340
+ const { displayName, senderNumber, senderName, appendPiSuffix } = options;
334
341
  for (let attempt = 0; attempt < 2; attempt++) {
335
342
  const inputText = (await ctx.ui.input(`Send a message to ${displayName}:`))?.trim() || '';
336
343
 
@@ -339,15 +346,14 @@ export class MenuHandler {
339
346
  continue;
340
347
  }
341
348
 
342
- const inputTextWithPiSuffix = inputText + ' π';
343
-
344
- const result = await this.whatsappService.sendMenuMessage(this.toJid(contact.number), inputTextWithPiSuffix);
349
+ const messageText = appendPiSuffix ? `${inputText} π` : inputText;
350
+ const result = await this.whatsappService.sendMenuMessage(this.toJid(senderNumber), messageText);
345
351
  if (result.success) {
346
352
  await this.recentsService.recordMessage({
347
353
  messageId: result.messageId ?? `${Date.now()}`,
348
- senderNumber: contact.number,
349
- senderName: contact.name,
350
- text: inputTextWithPiSuffix,
354
+ senderNumber,
355
+ senderName,
356
+ text: messageText,
351
357
  direction: 'outgoing',
352
358
  timestamp: Date.now()
353
359
  });
@@ -372,7 +378,8 @@ export class MenuHandler {
372
378
  }
373
379
 
374
380
  const options = [
375
- ...history.slice().reverse().map(message => this.formatHistoryOption(message.timestamp, message.direction, message.text)),
381
+ ...this.sortHistoryByMostRecent(history)
382
+ .map(message => this.formatHistoryOption(message.timestamp, message.direction, message.text)),
376
383
  'Back'
377
384
  ];
378
385
 
@@ -404,6 +411,39 @@ export class MenuHandler {
404
411
  return contact.name ? `${contact.name} ${contact.number}` : contact.number;
405
412
  }
406
413
 
414
+ private parseContactNumberOption(choice: string): string {
415
+ if (!choice.includes('(')) {
416
+ return choice;
417
+ }
418
+
419
+ const match = choice.match(/\((.*?)\)/);
420
+ return match?.[1] ?? choice;
421
+ }
422
+
423
+ private sortHistoryByMostRecent<T extends { timestamp: number }>(history: T[]): T[] {
424
+ return [...history].sort((left, right) => {
425
+ const dayComparison = this.getDayStart(right.timestamp) - this.getDayStart(left.timestamp);
426
+ if (dayComparison !== 0) {
427
+ return dayComparison;
428
+ }
429
+
430
+ return this.getTimeOfDay(right.timestamp) - this.getTimeOfDay(left.timestamp);
431
+ });
432
+ }
433
+
434
+ private getTimeOfDay(timestamp: number): number {
435
+ const date = new Date(timestamp);
436
+ return date.getHours() * 60 * 60 * 1000
437
+ + date.getMinutes() * 60 * 1000
438
+ + date.getSeconds() * 1000
439
+ + date.getMilliseconds();
440
+ }
441
+
442
+ private getDayStart(timestamp: number): number {
443
+ const date = new Date(timestamp);
444
+ return new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
445
+ }
446
+
407
447
  private formatHistoryOption(timestamp: number, direction: string, text: string): string {
408
448
  const marker = direction === 'outgoing' ? 'Sent' : 'Received';
409
449
  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
 
@@ -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;
@@ -112,7 +120,6 @@ export default function (pi: ExtensionAPI) {
112
120
  }
113
121
 
114
122
  // Check whatsapp flag — only auto-connect on initial startup, not reloads/new sessions
115
- const isWhatsappPiOn = event.reason === "startup" && pi.getFlag("whatsapp-pi-online") === true;
116
123
  const registered = await sessionManager.isRegistered();
117
124
 
118
125
  if (isWhatsappPiOn && registered) {
@@ -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
  }