whatsapp-pi 1.0.42 → 1.0.44

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.42",
3
+ "version": "1.0.44",
4
4
  "type": "module",
5
5
  "description": "WhatsApp integration extension for Pi",
6
6
  "main": "whatsapp-pi.ts",
@@ -27,6 +27,7 @@
27
27
  "author": "Rapha",
28
28
  "license": "MIT",
29
29
  "scripts": {
30
+ "lint": "eslint whatsapp-pi.ts \"src/**/*.ts\" \"tests/**/*.ts\"",
30
31
  "test": "vitest run",
31
32
  "typecheck": "tsc --noEmit"
32
33
  },
@@ -36,10 +37,14 @@
36
37
  "qrcode-terminal": "^0.12.0"
37
38
  },
38
39
  "devDependencies": {
40
+ "@eslint/js": "^9.39.4",
39
41
  "@mariozechner/pi-coding-agent": "latest",
40
42
  "@types/node": "^20.11.0",
41
43
  "@types/qrcode-terminal": "^0.12.2",
44
+ "@typescript-eslint/eslint-plugin": "^8.58.1",
45
+ "@typescript-eslint/parser": "^8.58.1",
42
46
  "@vitest/coverage-v8": "^1.6.1",
47
+ "eslint": "^9.39.4",
43
48
  "ts-node": "^10.9.2",
44
49
  "tsx": "^4.7.0",
45
50
  "typescript": "^5.3.0",
@@ -102,7 +102,7 @@ export class IncomingMediaService {
102
102
  }
103
103
 
104
104
  private async saveDocument(fileName: string, buffer: Buffer): Promise<string> {
105
- const sanitized = fileName.replace(/[^a-z0-9\._-]/gi, '_');
105
+ const sanitized = fileName.replace(/[^a-z0-9._-]/gi, '_');
106
106
  const savedFileName = `${Date.now()}_${sanitized}`;
107
107
  const documentDir = join(process.cwd(), '.pi-data', 'whatsapp', 'documents');
108
108
  const absolutePath = join(documentDir, savedFileName);
@@ -1,27 +1,33 @@
1
- import { useMultiFileAuthState } from '@whiskeysockets/baileys';
2
- import { join } from 'path';
3
- import { readFile, writeFile, mkdir, rm, readdir } from 'fs/promises';
1
+ import { useMultiFileAuthState } from '@whiskeysockets/baileys';
2
+ import { join } from 'path';
3
+ import { readFile, writeFile, mkdir, rm, rename } from 'fs/promises';
4
4
  import { homedir } from 'os';
5
5
  import { SessionStatus } from '../models/whatsapp.types.js';
6
6
 
7
- export interface Contact {
8
- number: string;
9
- name?: string;
10
- }
11
-
12
- export class SessionManager {
13
- // Data is stored in the user's home directory to persist across updates
14
- private readonly baseDir = join(homedir(), '.pi', 'whatsapp-pi');
15
- private readonly authStateDir = join(this.baseDir, 'auth');
16
- private readonly configPath = join(this.baseDir, 'config.json');
7
+ export interface Contact {
8
+ number: string;
9
+ name?: string;
10
+ }
11
+
12
+ export class SessionManager {
13
+ // Data is stored in the user's home directory to persist across updates
14
+ private readonly baseDir: string;
15
+ private readonly authStateDir: string;
16
+ private readonly configPath: string;
17
17
 
18
18
  private status: SessionStatus = 'logged-out';
19
19
  private allowList: Contact[] = [];
20
20
  private blockList: Contact[] = [];
21
21
  private ignoredNumbers: Contact[] = [];
22
22
  private hasAuthState = false;
23
- private openaiKey: string = '';
24
- private visionModel: string = 'gpt-4o';
23
+ private openaiKey: string = '';
24
+ private visionModel: string = 'gpt-4o';
25
+
26
+ constructor(baseDir = join(homedir(), '.pi', 'whatsapp-pi')) {
27
+ this.baseDir = baseDir;
28
+ this.authStateDir = join(this.baseDir, 'auth');
29
+ this.configPath = join(this.baseDir, 'config.json');
30
+ }
25
31
 
26
32
  private async ensureStorageDirectories() {
27
33
  await mkdir(this.baseDir, { recursive: true });
@@ -33,15 +39,17 @@ export class SessionManager {
33
39
  await this.ensureStorageDirectories();
34
40
  await this.loadConfig();
35
41
  await this.syncAuthStateFromDisk();
36
- } catch (error) {}
42
+ } catch {
43
+ // Initialization is best-effort; callers can continue with defaults.
44
+ }
37
45
  }
38
46
 
39
- private async loadConfig() {
40
- try {
41
- const data = await readFile(this.configPath, 'utf-8');
42
- const config = JSON.parse(data);
43
-
44
- const cleanContact = (item: any): Contact | null => {
47
+ private async loadConfig() {
48
+ try {
49
+ const data = await readFile(this.configPath, 'utf-8');
50
+ const { config, recovered } = this.parseConfig(data);
51
+
52
+ const cleanContact = (item: any): Contact | null => {
45
53
  if (typeof item === 'string') return { number: item };
46
54
  if (item && typeof item === 'object') {
47
55
  let num = item.number;
@@ -60,30 +68,91 @@ export class SessionManager {
60
68
  this.blockList = (config.blockList || []).map(cleanContact).filter(Boolean) as Contact[];
61
69
  this.ignoredNumbers = (config.ignoredNumbers || []).map(cleanContact).filter(Boolean) as Contact[];
62
70
  this.status = config.status || 'logged-out';
63
- this.hasAuthState = Boolean(config.hasAuthState);
64
- this.openaiKey = config.openaiKey || '';
65
- this.visionModel = config.visionModel || 'gpt-4o';
66
- } catch (error) {
67
- // File not found is fine
68
- }
69
- }
70
-
71
- public async saveConfig() {
72
- try {
73
- const config = {
74
- allowList: this.allowList,
71
+ this.hasAuthState = Boolean(config.hasAuthState);
72
+ this.openaiKey = config.openaiKey || '';
73
+ this.visionModel = config.visionModel || 'gpt-4o';
74
+
75
+ if (recovered) {
76
+ await this.saveConfig();
77
+ }
78
+ } catch {
79
+ // File not found is fine
80
+ }
81
+ }
82
+
83
+ private parseConfig(data: string): { config: any; recovered: boolean } {
84
+ try {
85
+ return { config: JSON.parse(data), recovered: false };
86
+ } catch (error) {
87
+ const objectEnd = this.findFirstJsonObjectEnd(data);
88
+ if (objectEnd < 0) {
89
+ throw error;
90
+ }
91
+
92
+ return {
93
+ config: JSON.parse(data.slice(0, objectEnd + 1)),
94
+ recovered: true
95
+ };
96
+ }
97
+ }
98
+
99
+ private findFirstJsonObjectEnd(data: string): number {
100
+ let depth = 0;
101
+ let inString = false;
102
+ let escaped = false;
103
+
104
+ for (let i = 0; i < data.length; i++) {
105
+ const char = data[i];
106
+
107
+ if (inString) {
108
+ if (escaped) {
109
+ escaped = false;
110
+ } else if (char === '\\') {
111
+ escaped = true;
112
+ } else if (char === '"') {
113
+ inString = false;
114
+ }
115
+ continue;
116
+ }
117
+
118
+ if (char === '"') {
119
+ inString = true;
120
+ continue;
121
+ }
122
+
123
+ if (char === '{') {
124
+ depth++;
125
+ } else if (char === '}') {
126
+ depth--;
127
+ if (depth === 0) {
128
+ return i;
129
+ }
130
+ }
131
+ }
132
+
133
+ return -1;
134
+ }
135
+
136
+ public async saveConfig() {
137
+ const tempPath = `${this.configPath}.${process.pid}.${Date.now()}.tmp`;
138
+ try {
139
+ const config = {
140
+ allowList: this.allowList,
75
141
  blockList: this.blockList,
76
142
  ignoredNumbers: this.ignoredNumbers,
77
143
  status: this.status,
78
144
  hasAuthState: this.hasAuthState,
79
- openaiKey: this.openaiKey,
80
- visionModel: this.visionModel
81
- };
82
- await writeFile(this.configPath, JSON.stringify(config, null, 2));
83
- } catch (error) {
84
- console.error('Failed to save config:', error);
85
- }
86
- }
145
+ openaiKey: this.openaiKey,
146
+ visionModel: this.visionModel
147
+ };
148
+ await mkdir(this.baseDir, { recursive: true });
149
+ await writeFile(tempPath, JSON.stringify(config, null, 2));
150
+ await rename(tempPath, this.configPath);
151
+ } catch (error) {
152
+ await rm(tempPath, { force: true }).catch(() => {});
153
+ console.error('Failed to save config:', error);
154
+ }
155
+ }
87
156
 
88
157
  getAllowList(): Contact[] {
89
158
  return this.allowList;
@@ -205,17 +274,10 @@ export class SessionManager {
205
274
  }
206
275
  }
207
276
 
208
- public async isRegistered(): Promise<boolean> {
209
- try {
210
- const credsPah = join(this.authStateDir, 'creds.json');
211
- await readFile(credsPah);
212
- this.hasAuthState = true;
213
- return true;
214
- } catch {
215
- await this.syncAuthStateFromDisk();
216
- return this.hasAuthState;
217
- }
218
- }
277
+ public async isRegistered(): Promise<boolean> {
278
+ await this.syncAuthStateFromDisk();
279
+ return this.hasAuthState;
280
+ }
219
281
 
220
282
  async markAuthStateAvailable() {
221
283
  if (!this.hasAuthState) {
@@ -229,19 +291,27 @@ export class SessionManager {
229
291
  return await useMultiFileAuthState(this.authStateDir);
230
292
  }
231
293
 
232
- private async syncAuthStateFromDisk() {
233
- try {
234
- const entries = await readdir(this.authStateDir);
235
- if (entries.length > 0) {
236
- if (!this.hasAuthState) {
237
- this.hasAuthState = true;
238
- await this.saveConfig();
239
- }
240
- }
241
- } catch {
242
- // Ignore missing directory / empty auth state
243
- }
244
- }
294
+ private async syncAuthStateFromDisk() {
295
+ const nextHasAuthState = await this.hasCredentialsFile();
296
+ const nextStatus = nextHasAuthState || this.status !== 'connected'
297
+ ? this.status
298
+ : 'disconnected';
299
+
300
+ if (nextHasAuthState !== this.hasAuthState || nextStatus !== this.status) {
301
+ this.hasAuthState = nextHasAuthState;
302
+ this.status = nextStatus;
303
+ await this.saveConfig();
304
+ }
305
+ }
306
+
307
+ private async hasCredentialsFile(): Promise<boolean> {
308
+ try {
309
+ await readFile(join(this.authStateDir, 'creds.json'));
310
+ return true;
311
+ } catch {
312
+ return false;
313
+ }
314
+ }
245
315
 
246
316
  async deleteAuthState() {
247
317
  try {
@@ -82,7 +82,7 @@ export class WhatsAppService {
82
82
  private restoreBaileysConsoleFilter?: () => void;
83
83
  private reconnectTimeout?: ReturnType<typeof setTimeout>;
84
84
  private onQRCode?: (qr: string) => void;
85
- private onMessage?: (m: unknown) => void;
85
+ private onMessage?: (m: MessagesUpsertEvent) => void;
86
86
  private onStatusUpdate?: (status: string) => void;
87
87
  private lastRemoteJid: string | null = null;
88
88
 
@@ -295,7 +295,7 @@ export class WhatsAppService {
295
295
  private async handlePairingQr(qr: string) {
296
296
  this.sessionManager.setStatus('pairing');
297
297
  this.onQRCode?.(qr);
298
- this.onStatusUpdate?.('| WhatsApp: type /whatsapp to connect');
298
+ this.onStatusUpdate?.('| WhatsApp: Disconnected');
299
299
  }
300
300
 
301
301
  private async handleConnectionOpen() {
@@ -339,35 +339,27 @@ export class WhatsAppService {
339
339
  console.error(`Connection closed [${statusCode}]. Reconnecting: ${shouldReconnect}`);
340
340
  }
341
341
 
342
- if (shouldTreatAsLoggedOut) {
343
- if (isAuthRejected && !isBadMac && allowPairingOnAuthFailure) {
344
- if (this.verboseMode) {
345
- console.error(`Session rejected [${statusCode}] - clearing auth state and starting pairing`);
346
- }
347
- await this.sessionManager.deleteAuthState();
348
- this.cleanupSocket();
349
- this.socket = undefined;
350
- this.isReconnecting = false;
351
- await this.start({ allowPairingOnAuthFailure: false });
352
- return;
353
- }
354
-
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
- }
342
+ if (shouldTreatAsLoggedOut) {
343
+ if (this.verboseMode) {
344
+ console.error(`Session rejected [${statusCode}] - preserving auth state`);
345
+ }
346
+ if (isBadMac) {
347
+ if (this.verboseMode) {
348
+ console.error('[WhatsApp-Pi] Bad MAC error detected. Your session keys are corrupted.');
349
+ console.error('[WhatsApp-Pi] Use Logoff (Delete Session) only if reconnect keeps failing.');
350
+ }
351
+ this.onStatusUpdate?.('| WhatsApp: Session Error (Bad MAC)');
352
+ } else if (isAuthRejected && allowPairingOnAuthFailure) {
353
+ this.onStatusUpdate?.('| WhatsApp: Session Preserved (Reconnect Failed)');
354
+ }
355
+ this.cleanupSocket();
356
+ this.isReconnecting = false;
357
+ await this.sessionManager.setStatus('disconnected');
358
+ if (!isBadMac) {
359
+ this.onStatusUpdate?.('| WhatsApp: Disconnected');
360
+ }
361
+ return;
362
+ }
371
363
 
372
364
  if (statusCode === DisconnectReason.connectionReplaced) {
373
365
  if (this.verboseMode) {
@@ -465,7 +457,7 @@ export class WhatsAppService {
465
457
  this.onQRCode = callback;
466
458
  }
467
459
 
468
- setMessageCallback(callback: (m: unknown) => void) {
460
+ setMessageCallback(callback: (m: MessagesUpsertEvent) => void) {
469
461
  this.onMessage = callback;
470
462
  }
471
463
 
@@ -63,13 +63,14 @@ export class MenuHandler {
63
63
  await this.whatsappService.stop();
64
64
  ctx.ui.notify('WhatsApp Agent Disconnected', 'warning');
65
65
  break;
66
- case 'Logoff (Delete Session)':
67
- const confirmLogoff = await ctx.ui.confirm('Logoff', 'Delete all credentials?');
68
- if (confirmLogoff) {
69
- await this.whatsappService.logout();
70
- ctx.ui.notify('Logged off and credentials deleted', 'info');
71
- }
72
- break;
66
+ case 'Logoff (Delete Session)': {
67
+ const confirmLogoff = await ctx.ui.confirm('Logoff', 'Delete all credentials?');
68
+ if (confirmLogoff) {
69
+ await this.whatsappService.logout();
70
+ ctx.ui.notify('Logged off and credentials deleted', 'info');
71
+ }
72
+ break;
73
+ }
73
74
  case 'Allowed Numbers':
74
75
  await this.manageAllowList(ctx);
75
76
  break;
@@ -29,11 +29,10 @@ export class MessageDetailView {
29
29
  ) {
30
30
  this.props.onClose();
31
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]';
32
+ }
33
+
34
+ render(width: number): string[] {
35
+ const bodyText = this.props.text.length > 0 ? this.props.text : '[No readable text available]';
37
36
 
38
37
  const availableWidth = Math.max(20, width - 4);
39
38
  const rawHeaderLines = [
package/whatsapp-pi.ts CHANGED
@@ -60,7 +60,7 @@ export default function (pi: ExtensionAPI) {
60
60
  };
61
61
 
62
62
  // Initial status setup
63
- pi.on("session_start", async (event, ctx) => {
63
+ pi.on("session_start", async (_event, ctx) => {
64
64
  _ctx = ctx;
65
65
  // Check verbose mode
66
66
  const isVerboseFlagSet = process.argv.includes("--verbose");
@@ -86,10 +86,10 @@ export default function (pi: ExtensionAPI) {
86
86
  await whatsappService.stop();
87
87
  }
88
88
  };
89
- whatsappService.setIncomingMessageRecorder(async (message) => {
90
- await recentsService.recordMessage({
91
- messageId: message.id,
92
- senderNumber: `+${message.remoteJid.split('@')[0]}`,
89
+ whatsappService.setIncomingMessageRecorder(async (message) => {
90
+ await recentsService.recordMessage({
91
+ messageId: message.id,
92
+ senderNumber: `+${message.remoteJid.split('@')[0]}`,
93
93
  senderName: message.pushName,
94
94
  text: message.text || '',
95
95
  direction: 'incoming',
@@ -97,19 +97,20 @@ export default function (pi: ExtensionAPI) {
97
97
  });
98
98
  });
99
99
 
100
- const savedStateEntry = [...ctx.sessionManager.getEntries()]
101
- .reverse()
102
- .find(entry => entry.type === "custom" && entry.customType === "whatsapp-state");
103
- const isWhatsappPiOn = event.reason === "startup" && pi.getFlag("whatsapp-pi-online") === true;
104
-
105
- if (savedStateEntry) {
106
- const data = (savedStateEntry as { data?: any }).data;
107
- if (data.status) {
108
- const restoredStatus = data.status === 'connected' && !isWhatsappPiOn
109
- ? 'disconnected'
110
- : data.status;
111
- await sessionManager.setStatus(restoredStatus);
112
- }
100
+ const savedStateEntry = [...ctx.sessionManager.getEntries()]
101
+ .reverse()
102
+ .find(entry => entry.type === "custom" && entry.customType === "whatsapp-state");
103
+ const isWhatsappPiOn = pi.getFlag("whatsapp-pi-online") === true;
104
+ const registered = await sessionManager.isRegistered();
105
+
106
+ if (savedStateEntry) {
107
+ const data = (savedStateEntry as { data?: any }).data;
108
+ if (data.status) {
109
+ const restoredStatus = data.status === 'connected' && !(isWhatsappPiOn && registered)
110
+ ? 'disconnected'
111
+ : data.status;
112
+ await sessionManager.setStatus(restoredStatus);
113
+ }
113
114
  if (Array.isArray(data.allowList)) {
114
115
  for (const n of data.allowList) {
115
116
  const num = typeof n === "string" ? n : n.number;
@@ -117,13 +118,10 @@ export default function (pi: ExtensionAPI) {
117
118
  await sessionManager.addNumber(num, name);
118
119
  }
119
120
  }
120
- }
121
-
122
- // Check whatsapp flag — only auto-connect on initial startup, not reloads/new sessions
123
- const registered = await sessionManager.isRegistered();
124
-
125
- if (isWhatsappPiOn && registered) {
126
- ctx.ui.setStatus('whatsapp', '| WhatsApp: Auto-connecting...');
121
+ }
122
+
123
+ if (isWhatsappPiOn && registered) {
124
+ ctx.ui.setStatus('whatsapp', '| WhatsApp: Auto-connecting...');
127
125
 
128
126
  // Retry logic (max 3 attempts, 3s delay)
129
127
  let attempts = 0;
@@ -133,7 +131,7 @@ export default function (pi: ExtensionAPI) {
133
131
  attempts++;
134
132
  try {
135
133
  await whatsappService.start({ allowPairingOnAuthFailure: false });
136
- } catch (error) {
134
+ } catch {
137
135
  if (attempts < maxAttempts) {
138
136
  ctx.ui.notify(`WhatsApp: Connection attempt ${attempts} failed. Retrying...`, 'warning');
139
137
  setTimeout(tryConnect, 3000);
@@ -145,9 +143,11 @@ export default function (pi: ExtensionAPI) {
145
143
  };
146
144
 
147
145
  await tryConnect();
148
- } else {
149
- ctx.ui.notify('WhatsApp: Use Connect / Reconnect WhatsApp. QR code will appear only if pairing is needed.', 'info');
150
- }
146
+ } else if (isWhatsappPiOn) {
147
+ ctx.ui.notify('WhatsApp: Auto-connect requested, but no saved WhatsApp credentials were found. Use Connect WhatsApp once to scan the QR code.', 'warning');
148
+ } else {
149
+ ctx.ui.notify('WhatsApp: Use Connect / Reconnect WhatsApp. QR code will appear only if pairing is needed.', 'info');
150
+ }
151
151
 
152
152
  ctx.ui.notify('WhatsApp: Session reset via /new is now fully supported.', 'info');
153
153
 
@@ -157,7 +157,7 @@ export default function (pi: ExtensionAPI) {
157
157
  if (code !== 0 && code !== 99) { // 99 is a common exit code for -v in some versions
158
158
  throw new Error(`pdftotext returned code ${code}`);
159
159
  }
160
- } catch (e) {
160
+ } catch {
161
161
  ctx.ui.notify('WhatsApp: pdftotext not found. PDF document support will be limited to storage only.', 'warning');
162
162
  logger.warn('[WhatsApp-Pi] Warning: pdftotext not found in system PATH.');
163
163
  }
@@ -165,8 +165,8 @@ export default function (pi: ExtensionAPI) {
165
165
 
166
166
  // Handle incoming messages by injecting them as user prompts
167
167
  whatsappService.setMessageCallback(async (m) => {
168
- const msg = m.messages[0];
169
- if (!msg.message) return;
168
+ const msg = m.messages?.[0];
169
+ if (!msg?.message) return;
170
170
 
171
171
  const sender = msg.key.remoteJid?.split('@')[0] || "unknown";
172
172
  const pushName = msg.pushName || "WhatsApp User";
@@ -333,7 +333,7 @@ export default function (pi: ExtensionAPI) {
333
333
  } else {
334
334
  ctx.ui.notify(`Failed to send WhatsApp reply`, 'error');
335
335
  }
336
- } catch (error) {
336
+ } catch {
337
337
  ctx.ui.notify(`Failed to send WhatsApp reply`, 'error');
338
338
  }
339
339
  }