whatsapp-pi 1.0.51 → 1.0.53

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -19,6 +19,7 @@ Pi is a powerful agentic AI coding assistant that operates in your terminal. Thi
19
19
  - Manage aliases and print allowed contacts from the menu
20
20
  - **Allowed Groups**: Control which WhatsApp groups can interact with Pi
21
21
  - Add group JIDs with optional aliases
22
+ - Choose reaction mode per group: **Active** (reply to all allowed group messages) or **Passive** (reply only when mentioned with @)
22
23
  - Only groups in Allowed Groups are processed by the agent
23
24
  - **Recents & History**: Browse recent conversations, inspect full message history, and reply from message detail view
24
25
  - **Reliable Messaging**: Queue-based message sending with retry logic
@@ -27,7 +28,7 @@ Pi is a powerful agentic AI coding assistant that operates in your terminal. Thi
27
28
  - **Media Support**:
28
29
  - **Vision Analysis**: Automatically forwards WhatsApp images to Pi for analysis.
29
30
  - **Audio Transcription**: Transcribes voice notes when Whisper is installed.
30
- - **Document Handling**: Downloads and stores documents (PDF, text) for agent access.
31
+ - **Document Handling**: Downloads and stores documents (PDF, text) for agent access; PDFs include a bounded text preview when readable.
31
32
 
32
33
  ## Prerequisites
33
34
 
@@ -36,10 +37,8 @@ To enable audio transcription features:
36
37
  python -m pip install -U openai-whisper
37
38
  ```
38
39
 
39
- To enable PDF reading capabilities (required for the agent to process documents):
40
- - **Linux**: `sudo apt-get install poppler-utils`
41
- - **macOS**: `brew install poppler`
42
- - **Windows**: Install `poppler` (e.g., via Scoop) and add to PATH.
40
+ PDF documents are parsed locally and do not require extra system utilities.
41
+ If a PDF cannot be parsed automatically, it is still saved and forwarded with a clear fallback notice.
43
42
 
44
43
  ## Quick Start
45
44
 
@@ -126,7 +125,8 @@ pi -e whatsapp-pi.ts --whatsapp-pi-online
126
125
 
127
126
  ### Allowed Groups Management
128
127
  - **Add Group** - Add a WhatsApp group JID to the allowed groups list (format: 120363012345@g.us)
129
- - **Select a group** - Open a submenu with **History**, **Send Message**, **Print Group JID**, alias actions, **Remove Group**, and **Back**
128
+ - **Select a group** - Open a submenu with **History**, **Send Message**, **Print Group JID**, **Reaction Mode**, alias actions, **Remove Group**, and **Back**
129
+ - **Reaction Mode** - Switch between **Active** and **Passive** behavior for that group
130
130
  - **Back** - Return to main menu
131
131
 
132
132
  ### Recents Management
@@ -162,9 +162,10 @@ npm test
162
162
 
163
163
  - **Auto-Connect Support**: Use the `--whatsapp-pi-online` flag to connect on startup when credentials already exist.
164
164
  - **Group-Only Mode**: Use `--whatsapp-group <jid>` to bind Pi to a single WhatsApp group. The group must also be present in Allowed Groups.
165
+ - **Allowed Group Reaction Mode**: Each allowed group can be set to Active or Passive. Passive mode only replies when the bot is directly mentioned with @.
165
166
  - **Recents Store**: Recent conversations and message history are persisted in `~/.pi/whatsapp-pi/recents/recents.json`.
166
167
  - **Message Detail / Reply**: Open a message from history to inspect full content and reply with `R`.
167
- - **Media Support**: Images are forwarded for vision analysis, audio is transcribed with Whisper, and documents are saved under `./.pi-data/whatsapp/documents/`.
168
+ - **Media Support**: Images are forwarded for vision analysis, audio is transcribed with Whisper, and PDFs are saved under `./.pi-data/whatsapp/documents/` with local text preview when available.
168
169
  - **Session Handling**: Saved state, allow list, and startup reconnects are restored automatically when available.
169
170
  - **Intelligent Message Filtering**: Messages ending with `π` are ignored to prevent bot loops.
170
171
  - **Storage Management**: Persistent data lives under `.pi-data/` plus the recents store in the user home directory.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "whatsapp-pi",
3
- "version": "1.0.51",
3
+ "version": "1.0.53",
4
4
  "type": "module",
5
5
  "description": "WhatsApp integration extension for Pi",
6
6
  "main": "whatsapp-pi.ts",
@@ -32,6 +32,7 @@
32
32
  "typecheck": "tsc --noEmit"
33
33
  },
34
34
  "dependencies": {
35
+ "@llamaindex/liteparse": "^1.5.3",
35
36
  "baileys": "^6.7.21",
36
37
  "pino": "^10.3.1",
37
38
  "qrcode-terminal": "^0.12.0"
package/src/i18n.ts CHANGED
@@ -102,6 +102,11 @@ const fallback = {
102
102
  "menu.allowedGroups.group.history": "History",
103
103
  "menu.allowedGroups.group.sendMessage": "Send Message",
104
104
  "menu.allowedGroups.group.printGroup": "Print Group JID",
105
+ "menu.allowedGroups.group.reactionMode": "Reaction Mode",
106
+ "menu.allowedGroups.group.reactionMode.title": "Reaction Mode • {displayName}",
107
+ "menu.allowedGroups.group.reactionMode.active": "Active",
108
+ "menu.allowedGroups.group.reactionMode.passive": "Passive",
109
+ "menu.allowedGroups.group.reactionMode.updated": "Reaction mode set to {mode} for {displayName}",
105
110
  "menu.allowedGroups.group.removeAlias": "Remove Alias",
106
111
  "menu.allowedGroups.group.addAlias": "Add Alias",
107
112
  "menu.allowedGroups.group.removeGroup": "Remove Group",
@@ -197,6 +202,8 @@ const fallback = {
197
202
  "incoming.media.documentSize": "Size: {size}",
198
203
  "incoming.media.documentLocation": "Location: {relativePath}",
199
204
  "incoming.media.documentDescription": "Description: {caption}",
205
+ "incoming.media.documentPdfPreviewHeading": "PDF text preview:",
206
+ "incoming.media.documentPdfFallbackNotice": "PDF text was not extracted automatically. The file is saved at the path above.",
200
207
  "incoming.media.documentDownloadFailed": "[WhatsApp-Pi] Failed to download document:",
201
208
  "incoming.media.documentDownloadFailedText": "[Document: {fileName} (download failed)]",
202
209
  "audio.emptyTranscription": "[Empty transcription]",
@@ -304,6 +311,11 @@ const translations: Record<Locale, Partial<Record<Key, string>>> = {
304
311
  "menu.allowedGroups.group.history": "Histórico",
305
312
  "menu.allowedGroups.group.sendMessage": "Enviar mensagem",
306
313
  "menu.allowedGroups.group.printGroup": "Mostrar JID do grupo",
314
+ "menu.allowedGroups.group.reactionMode": "Modo de reação",
315
+ "menu.allowedGroups.group.reactionMode.title": "Modo de reação • {displayName}",
316
+ "menu.allowedGroups.group.reactionMode.active": "Ativo",
317
+ "menu.allowedGroups.group.reactionMode.passive": "Passivo",
318
+ "menu.allowedGroups.group.reactionMode.updated": "Modo de reação definido como {mode} para {displayName}",
307
319
  "menu.allowedGroups.group.removeAlias": "Remover apelido",
308
320
  "menu.allowedGroups.group.addAlias": "Adicionar apelido",
309
321
  "menu.allowedGroups.group.removeGroup": "Remover grupo",
@@ -375,6 +387,8 @@ const translations: Record<Locale, Partial<Record<Key, string>>> = {
375
387
  "incoming.media.documentSize": "Tamanho: {size}",
376
388
  "incoming.media.documentLocation": "Local: {relativePath}",
377
389
  "incoming.media.documentDescription": "Descrição: {caption}",
390
+ "incoming.media.documentPdfPreviewHeading": "Prévia do texto do PDF:",
391
+ "incoming.media.documentPdfFallbackNotice": "O texto do PDF não pôde ser extraído automaticamente. O arquivo foi salvo no caminho acima.",
378
392
  "incoming.media.documentDownloadFailed": "[WhatsApp-Pi] Falha ao baixar documento:",
379
393
  "incoming.media.documentDownloadFailedText": "[Documento: {fileName} (download falhou)]",
380
394
  },
@@ -10,6 +10,8 @@ export interface AllowList {
10
10
  numbers: string[];
11
11
  }
12
12
 
13
+ export type ReactionMode = 'active' | 'passive';
14
+
13
15
  export interface IncomingMessage {
14
16
  id: string;
15
17
  remoteJid: string;
@@ -1,6 +1,7 @@
1
1
  import { downloadContentFromMessage } from 'baileys';
2
2
  import { mkdir, writeFile } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
+ import { LiteParse } from '@llamaindex/liteparse';
4
5
  import { AudioService } from './audio.service.js';
5
6
  import type { IncomingResolution } from './incoming-message.resolver.js';
6
7
  import { WhatsAppPiLogger } from './whatsapp-pi.logger.js';
@@ -12,7 +13,11 @@ export interface ProcessedIncomingContent {
12
13
  imageMimeType?: string;
13
14
  }
14
15
 
16
+ const PDF_PREVIEW_LIMIT = 1200;
17
+
15
18
  export class IncomingMediaService {
19
+ private readonly pdfParser = new LiteParse({ ocrEnabled: true });
20
+
16
21
  constructor(
17
22
  private readonly audioService: AudioService,
18
23
  private readonly logger = new WhatsAppPiLogger(false)
@@ -80,6 +85,15 @@ export class IncomingMediaService {
80
85
  + t('incoming.media.documentSize', { size: this.formatFileSize(fileSize) }) + '\n'
81
86
  + t('incoming.media.documentLocation', { relativePath });
82
87
 
88
+ if (this.isPdfDocument(fileName, mimeType)) {
89
+ const preview = await this.extractPdfPreview(buffer);
90
+ if (preview) {
91
+ text += `\n\n${t('incoming.media.documentPdfPreviewHeading')}\n${preview}`;
92
+ } else {
93
+ text += `\n\n${t('incoming.media.documentPdfFallbackNotice')}`;
94
+ }
95
+ }
96
+
83
97
  if (documentMessage.caption) {
84
98
  text += `\n\n${t('incoming.media.documentDescription', { caption: documentMessage.caption })}`;
85
99
  }
@@ -91,6 +105,34 @@ export class IncomingMediaService {
91
105
  }
92
106
  }
93
107
 
108
+ private async extractPdfPreview(buffer: Buffer): Promise<string | null> {
109
+ try {
110
+ const result = await this.pdfParser.parse(buffer);
111
+ return this.formatPdfPreview(result.text);
112
+ } catch (error) {
113
+ this.logger.warn('[WhatsApp-Pi] PDF parsing failed, falling back to storage-only behavior.', error);
114
+ return null;
115
+ }
116
+ }
117
+
118
+ private formatPdfPreview(text: string | undefined | null): string | null {
119
+ const normalized = (text || '').replace(/\r\n/g, '\n').trim();
120
+ if (!normalized) {
121
+ return null;
122
+ }
123
+
124
+ if (normalized.length <= PDF_PREVIEW_LIMIT) {
125
+ return normalized;
126
+ }
127
+
128
+ return `${normalized.slice(0, PDF_PREVIEW_LIMIT)}…`;
129
+ }
130
+
131
+ private isPdfDocument(fileName: string, mimeType: string): boolean {
132
+ const normalizedMimeType = mimeType.toLowerCase().split(';')[0].trim();
133
+ return normalizedMimeType === 'application/pdf' || fileName.toLowerCase().endsWith('.pdf');
134
+ }
135
+
94
136
  private async downloadMessage(message: any, type: 'image' | 'document'): Promise<Buffer> {
95
137
  const stream = await downloadContentFromMessage(message, type);
96
138
  let buffer = Buffer.from([]);
@@ -2,12 +2,13 @@ import { useMultiFileAuthState } from 'baileys';
2
2
  import { join } from 'path';
3
3
  import { readFile, writeFile, mkdir, rm, rename } from 'fs/promises';
4
4
  import { homedir } from 'os';
5
- import { SessionStatus } from '../models/whatsapp.types.js';
5
+ import { SessionStatus, type ReactionMode } from '../models/whatsapp.types.js';
6
6
  import { t } from '../i18n.js';
7
7
 
8
8
  export interface Contact {
9
9
  number: string;
10
10
  name?: string;
11
+ reactionMode?: ReactionMode;
11
12
  }
12
13
 
13
14
  export class SessionManager {
@@ -72,7 +73,10 @@ export class SessionManager {
72
73
  num = num.number;
73
74
  }
74
75
  if (typeof num === 'string') {
75
- return { number: num, name: item.name };
76
+ const reactionMode = item.reactionMode === 'active' || item.reactionMode === 'passive'
77
+ ? item.reactionMode
78
+ : undefined;
79
+ return { number: num, name: item.name, reactionMode };
76
80
  }
77
81
  }
78
82
  return null;
@@ -81,6 +85,8 @@ export class SessionManager {
81
85
  const loadedAllowList = (config.allowList || []).map(cleanContact).filter(Boolean) as Contact[];
82
86
  const loadedAllowedGroups = (config.allowedGroups || []).map(cleanContact).filter(Boolean) as Contact[];
83
87
  const migratedGroups = loadedAllowList.filter(c => SessionManager.isGroupJid(c.number));
88
+ const needsReactionModeBackfill = loadedAllowedGroups.some(group => !group.reactionMode)
89
+ || migratedGroups.some(group => !group.reactionMode);
84
90
  this.allowList = loadedAllowList.filter(c => !SessionManager.isGroupJid(c.number));
85
91
  this.allowedGroups = this.mergeContacts(loadedAllowedGroups, migratedGroups);
86
92
  this.ignoredNumbers = (config.ignoredNumbers || []).map(cleanContact).filter(Boolean) as Contact[];
@@ -89,7 +95,7 @@ export class SessionManager {
89
95
  this.openaiKey = config.openaiKey || '';
90
96
  this.visionModel = config.visionModel || 'gpt-4o';
91
97
 
92
- if (recovered) {
98
+ if (recovered || needsReactionModeBackfill) {
93
99
  await this.saveConfig();
94
100
  }
95
101
  } catch {
@@ -240,7 +246,7 @@ export class SessionManager {
240
246
 
241
247
  const existing = this.allowedGroups.find(c => c.number === groupJid);
242
248
  if (!existing) {
243
- this.allowedGroups.push({ number: groupJid, name });
249
+ this.allowedGroups.push({ number: groupJid, name, reactionMode: 'active' });
244
250
  this.ignoredNumbers = this.ignoredNumbers.filter(c => c.number !== groupJid);
245
251
  await this.saveConfig();
246
252
  return;
@@ -250,6 +256,11 @@ export class SessionManager {
250
256
  existing.name = name;
251
257
  await this.saveConfig();
252
258
  }
259
+
260
+ if (!existing.reactionMode) {
261
+ existing.reactionMode = 'active';
262
+ await this.saveConfig();
263
+ }
253
264
  }
254
265
 
255
266
  async removeAllowedGroup(groupJid: string) {
@@ -297,6 +308,20 @@ export class SessionManager {
297
308
  await this.saveConfig();
298
309
  }
299
310
 
311
+ getAllowedGroupReactionMode(groupJid: string): ReactionMode {
312
+ return this.getAllowedGroup(groupJid)?.reactionMode || 'active';
313
+ }
314
+
315
+ async setAllowedGroupReactionMode(groupJid: string, reactionMode: ReactionMode) {
316
+ const group = this.getAllowedGroup(groupJid);
317
+ if (!group) {
318
+ return;
319
+ }
320
+
321
+ group.reactionMode = reactionMode;
322
+ await this.saveConfig();
323
+ }
324
+
300
325
  async removeAllowedGroupAlias(groupJid: string) {
301
326
  const group = this.getAllowedGroup(groupJid);
302
327
  if (!group || !group.name) {
@@ -336,11 +361,19 @@ export class SessionManager {
336
361
  const existing = merged.find(c => c.number === contact.number);
337
362
  if (!existing) {
338
363
  merged.push(contact);
339
- } else if (!existing.name && contact.name) {
340
- existing.name = contact.name;
364
+ } else {
365
+ if (!existing.name && contact.name) {
366
+ existing.name = contact.name;
367
+ }
368
+ if (!existing.reactionMode && contact.reactionMode) {
369
+ existing.reactionMode = contact.reactionMode;
370
+ }
341
371
  }
342
372
  }
343
- return merged;
373
+ return merged.map(contact => ({
374
+ ...contact,
375
+ reactionMode: contact.reactionMode || 'active'
376
+ }));
344
377
  }
345
378
 
346
379
  public async isRegistered(): Promise<boolean> {
@@ -42,9 +42,16 @@ interface IncomingMessageKey {
42
42
  participant?: string;
43
43
  }
44
44
 
45
+ interface IncomingMessageContextInfo {
46
+ mentionedJid?: string[];
47
+ }
48
+
45
49
  interface IncomingMessageContent {
46
50
  conversation?: string;
47
- extendedTextMessage?: { text?: string };
51
+ extendedTextMessage?: {
52
+ text?: string;
53
+ contextInfo?: IncomingMessageContextInfo;
54
+ };
48
55
  }
49
56
 
50
57
  interface IncomingMessageLike {
@@ -59,6 +66,7 @@ interface MessagesUpsertEvent {
59
66
  }
60
67
 
61
68
  interface WhatsAppSocketLike {
69
+ user?: { id?: string; lid?: string };
62
70
  ev: {
63
71
  on(event: 'connection.update', handler: (update: ConnectionUpdateEvent) => void | Promise<void>): void;
64
72
  on(event: 'creds.update', handler: () => void | Promise<void>): void;
@@ -170,6 +178,48 @@ export class WhatsAppService {
170
178
  return `${digits}@s.whatsapp.net`;
171
179
  }
172
180
 
181
+ private normalizeJidForComparison(jid: string): string {
182
+ const [localPart, domain = ''] = jid.split('@');
183
+ const normalizedLocal = localPart.split(':')[0];
184
+ return domain ? `${normalizedLocal}@${domain}` : normalizedLocal;
185
+ }
186
+
187
+ private normalizeJidIdentity(jid: string): string {
188
+ return this.normalizeJidForComparison(jid).split('@')[0];
189
+ }
190
+
191
+ private getAgentJidCandidates(): string[] {
192
+ const user = this.socket?.user;
193
+ const rawJids = [user?.id, user?.lid].filter((jid): jid is string => Boolean(jid));
194
+ const candidates = new Set<string>();
195
+
196
+ for (const jid of rawJids) {
197
+ const normalized = this.normalizeJidForComparison(jid);
198
+ candidates.add(normalized);
199
+ candidates.add(this.normalizeJidIdentity(jid));
200
+ }
201
+
202
+ return [...candidates];
203
+ }
204
+
205
+ private messageHasDirectMention(message: IncomingMessageLike): boolean {
206
+ const mentionedJids = message.message?.extendedTextMessage?.contextInfo?.mentionedJid || [];
207
+ if (mentionedJids.length === 0) {
208
+ return false;
209
+ }
210
+
211
+ const agentCandidates = this.getAgentJidCandidates();
212
+ if (agentCandidates.length === 0) {
213
+ return false;
214
+ }
215
+
216
+ return mentionedJids.some(jid => {
217
+ const normalizedMention = this.normalizeJidForComparison(jid);
218
+ const mentionIdentity = this.normalizeJidIdentity(jid);
219
+ return agentCandidates.includes(normalizedMention) || agentCandidates.includes(mentionIdentity);
220
+ });
221
+ }
222
+
173
223
  private getDisconnectStatusCode(error: unknown): number | undefined {
174
224
  if (!error || typeof error !== 'object') {
175
225
  return undefined;
@@ -496,6 +546,10 @@ export class WhatsAppService {
496
546
  void this.recordIncomingMessage(message, remoteJid, text);
497
547
 
498
548
  const pushName = message.pushName || undefined;
549
+ const groupAllowed = isGroup && this.sessionManager.isAllowedGroup(remoteJid);
550
+ const passiveGroupBlocked = groupAllowed
551
+ && this.sessionManager.getAllowedGroupReactionMode(remoteJid) === 'passive'
552
+ && !this.messageHasDirectMention(message);
499
553
 
500
554
  if (this.boundGroupJid) {
501
555
  if (!this.sessionManager.isAllowedGroup(this.boundGroupJid)) {
@@ -503,6 +557,10 @@ export class WhatsAppService {
503
557
  return;
504
558
  }
505
559
 
560
+ if (this.sessionManager.getAllowedGroupReactionMode(this.boundGroupJid) === 'passive' && !this.messageHasDirectMention(message)) {
561
+ return;
562
+ }
563
+
506
564
  this.lastRemoteJid = remoteJid;
507
565
  this.onMessage?.(payload);
508
566
  return;
@@ -516,6 +574,10 @@ export class WhatsAppService {
516
574
  return;
517
575
  }
518
576
 
577
+ if (passiveGroupBlocked) {
578
+ return;
579
+ }
580
+
519
581
  this.lastRemoteJid = remoteJid;
520
582
  this.onMessage?.(payload);
521
583
  }
@@ -255,11 +255,12 @@ export class MenuHandler {
255
255
  const historyLabel = t('menu.allowedGroups.group.history');
256
256
  const sendMessageLabel = t('menu.allowedGroups.group.sendMessage');
257
257
  const printGroupLabel = t('menu.allowedGroups.group.printGroup');
258
+ const reactionModeLabel = t('menu.allowedGroups.group.reactionMode');
258
259
  const removeAliasLabel = t('menu.allowedGroups.group.removeAlias');
259
260
  const addAliasLabel = t('menu.allowedGroups.group.addAlias');
260
261
  const removeGroupLabel = t('menu.allowedGroups.group.removeGroup');
261
262
  const backLabel = t('menu.allowedGroups.group.back');
262
- const options = [historyLabel, sendMessageLabel, printGroupLabel];
263
+ const options = [historyLabel, sendMessageLabel, printGroupLabel, reactionModeLabel];
263
264
  if (group.name) {
264
265
  options.push(removeAliasLabel);
265
266
  } else {
@@ -287,6 +288,12 @@ export class MenuHandler {
287
288
  return;
288
289
  }
289
290
 
291
+ if (choice === reactionModeLabel) {
292
+ await this.manageAllowedGroupReactionMode(ctx, group);
293
+ await this.manageAllowedGroup(ctx, group);
294
+ return;
295
+ }
296
+
290
297
  if (choice === addAliasLabel) {
291
298
  const alias = await ctx.ui.input(t('menu.allowedGroups.enterAlias', { groupJid: group.number }));
292
299
  const trimmedAlias = alias?.trim() || '';
@@ -469,6 +476,30 @@ export class MenuHandler {
469
476
  });
470
477
  }
471
478
 
479
+ private async manageAllowedGroupReactionMode(ctx: ExtensionCommandContext, group: Contact) {
480
+ const displayName = this.formatAllowedGroupOption(group);
481
+ const title = t('menu.allowedGroups.group.reactionMode.title', { displayName });
482
+ const activeLabel = t('menu.allowedGroups.group.reactionMode.active');
483
+ const passiveLabel = t('menu.allowedGroups.group.reactionMode.passive');
484
+ const backLabel = t('menu.allowedGroups.group.back');
485
+ const currentMode = this.sessionManager.getAllowedGroupReactionMode(group.number);
486
+ const options = [activeLabel, passiveLabel, backLabel];
487
+
488
+ const choice = await ctx.ui.select(title, options);
489
+
490
+ if (choice === backLabel || !choice) {
491
+ return;
492
+ }
493
+
494
+ if (choice === activeLabel || choice === passiveLabel) {
495
+ const nextMode = choice === activeLabel ? 'active' : 'passive';
496
+ if (currentMode !== nextMode) {
497
+ await this.sessionManager.setAllowedGroupReactionMode(group.number, nextMode);
498
+ }
499
+ ctx.ui.notify(t('menu.allowedGroups.group.reactionMode.updated', { displayName, mode: choice }), 'info');
500
+ }
501
+ }
502
+
472
503
  private async sendPromptedMenuMessage(
473
504
  ctx: ExtensionCommandContext,
474
505
  options: {
package/whatsapp-pi.ts CHANGED
@@ -183,17 +183,6 @@ export default function (pi: ExtensionAPI) {
183
183
  }
184
184
 
185
185
  ctx.ui.notify('WhatsApp: Session reset via /new is now fully supported.', 'info');
186
-
187
- // Verify pdftotext availability for document support
188
- try {
189
- const { code } = await pi.exec('pdftotext', ['-v']);
190
- if (code !== 0 && code !== 99) { // 99 is a common exit code for -v in some versions
191
- throw new Error(`pdftotext returned code ${code}`);
192
- }
193
- } catch {
194
- ctx.ui.notify('WhatsApp: pdftotext not found. PDF document support will be limited to storage only.', 'warning');
195
- logger.warn('[WhatsApp-Pi] Warning: pdftotext not found in system PATH.');
196
- }
197
186
  });
198
187
 
199
188
  // Track whether send_wa_message tool already sent a reply this turn